diff --git a/cmd/cthulhu/cthulhu b/cmd/cthulhu/cthulhu new file mode 100755 index 0000000..3b28630 Binary files /dev/null and b/cmd/cthulhu/cthulhu differ diff --git a/docs/testing/announcements.md b/docs/testing/announcements.md new file mode 100644 index 0000000..6f58242 --- /dev/null +++ b/docs/testing/announcements.md @@ -0,0 +1,64 @@ +# AT-SPI announcements + +Providers can ask the reader to speak a message without moving focus by +emitting the AT-SPI `object:announcement` event. The event's `any_data` +carries the text and `detail1` carries the politeness (`AtspiLive`: 1 is +polite, 2 is assertive). Orca presents the text verbatim; Cthulhu now does +the same, with a Braille flash through the system-role output queue. + +The motivating provider is the Niri compositor (25.08+), which exposes its +own UI through AccessKit. A capture from Niri 26.04 on 2026-10-31 shows a +workspace switch emits `object:property-change:accessible-name` and then +`object:announcement` (polite) with text such as `Workspace 2 Mistworld` on +a label in the `niri` application, about 10 ms before the newly focused +window activates. Niri alternates live-node identities (and may append a +space) so repeated messages still emit; Cthulhu trims the payload and +dedupes one provider's repeat inside 300 ms. + +Announcements bypass background-event filtering and foreground epochs: the +provider explicitly requested speech and may never own focus. They do not +move focus, review, or reading state. Screen-lock and secure-input gates +still suppress them. Assertive announcements interrupt; polite ones append. +Payload text is bounded by the adapter's existing snapshot sanitization. + +## Automated checks + +Run from the repository root with the normal native build dependencies: + +```sh +go test ./internal/atspi ./internal/app -run Announcement -count=1 +python3 tests/native_test_session.py env CTHULHU_NATIVE_ANNOUNCEMENT=1 go test ./internal/atspi -run '^TestNativeAnnouncementEventDelivered$' -count=1 -v -timeout=40s +``` + +The native check uses `tests/gtk_announce.py`, a GTK4 fixture whose +`announce` calls must arrive as adapter events with the exact payload and +politeness (medium priority arrives polite, high arrives assertive). + +On hosts without a systemd user manager, D-Bus activation of the AT-SPI +registry can fail inside the private session (the pre-existing native +checks fail the same way). Starting the a11y bus and registry manually +inside the session works around it: + +```sh +python3 tests/native_test_session.py env CTHULHU_NATIVE_ANNOUNCEMENT=1 sh -c '/usr/lib/at-spi-bus-launcher & sleep 1; ADDR=$(busctl --user call org.a11y.Bus /org/a11y/bus org.a11y.Bus GetAddress | sed "s/^s //;s/\"//g"); AT_SPI_BUS_ADDRESS=$ADDR /usr/lib/at-spi2-registryd & sleep 1; go test ./internal/atspi/ -run "^TestNativeAnnouncementEventDelivered$" -count=1 -v -timeout=40s' +``` + +## Live acceptance (Niri) + +Requires the maintainer's authorization to replace the running reader. +Report automated, audible-speech, and physical-Braille results separately. + +1. Start the built reader in a full Niri session (started via a display + manager or `niri-session`, so Niri exposes its accessibility tree). +2. Switch between named, unnamed, and the trailing empty workspace with + Niri's own binds. Each switch speaks the workspace label once, before + the newly focused window, and focus presentation still follows. +3. Burst-switch several times quickly. Only settled announcements speak; + no duplicates from the paired accessible-name change. +4. Cycle the AltTab switcher and open the exit dialog (`Super+Shift+E`) + and the hotkeys list (`Super+Shift+/`). Selected windows and dialog + text are announced as with Orca on the same session. +5. Introduce a temporary config parse error and reload. The error is + announced. +6. Lock the screen and switch workspaces. Nothing is spoken or flashed + until unlock. diff --git a/docs/testing/background-events.md b/docs/testing/background-events.md index ed34535..0111618 100644 --- a/docs/testing/background-events.md +++ b/docs/testing/background-events.md @@ -12,7 +12,8 @@ correlation before content capture. Lifecycle invalidation still runs, but a background provider no longer clears another provider's cached document lines. Window creation, activation, deactivation, destruction and defunct events keep -their existing handling. Native alerts, notification daemons and supported +their existing handling. Announcement events are explicit requests to speak +and also bypass the filter; see [announcements](announcements.md). Native alerts, notification daemons and supported Steam toasts retain their notification paths. Menu popups retain existing foreground checks. Verified active/showing `mate-panel` menu windows may pass across provider boundaries, including over an empty desktop; ordinary panel diff --git a/internal/app/announcement_test.go b/internal/app/announcement_test.go new file mode 100644 index 0000000..b51eeda --- /dev/null +++ b/internal/app/announcement_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// Fixture recorded from Niri 26.04 on 2026-10-31: a workspace switch emits +// object:property-change:accessible-name and then object:announcement +// (detail1=1, polite) with the same text on a label in the niri application, +// about 10ms before the newly focused window's activation events. +func announcementEvent(text string, politeness int) atspi.Event { + source := atspi.Snapshot{BusName: ":1.20", Path: "/org/a11y/atspi/accessible/2", Role: "label", Name: text, ApplicationName: "niri"} + event := atspi.NewEventWithPayload(atspi.AnnouncementEvent, source, text) + event.Detail1 = politeness + return event +} + +func announcementFixture(t *testing.T) (*fakeSpeechQueue, *fakeAdapter, func()) { + t.Helper() + speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + }() + return speech, adapter, func() { cancel(); <-done } +} + +func TestAnnouncementSpeaksPoliteWorkspaceSwitch(t *testing.T) { + speech, adapter, done := announcementFixture(t) + defer done() + + adapter.events <- announcementEvent("Workspace 2 Mistworld", 1) + select { + case request := <-speech.requests: + if request.Text != "Workspace 2 Mistworld" || request.Role != "system" || request.Cause != atspi.AnnouncementEvent || + !request.Append || !request.BypassFocusGate { + t.Fatalf("announcement request = %+v", request) + } + case <-time.After(time.Second): + t.Fatal("announcement was not spoken") + } +} + +func TestAnnouncementAssertiveInterrupts(t *testing.T) { + speech, adapter, done := announcementFixture(t) + defer done() + + adapter.events <- announcementEvent("Config parse error", 2) + select { + case request := <-speech.requests: + if request.Text != "Config parse error" || request.Append { + t.Fatalf("assertive request = %+v", request) + } + case <-time.After(time.Second): + t.Fatal("assertive announcement was not spoken") + } +} + +func TestAnnouncementDedupesTrimmedRepeatButNotNewText(t *testing.T) { + speech, adapter, done := announcementFixture(t) + defer done() + + adapter.events <- announcementEvent("Workspace 2 Mistworld", 1) + // Niri can append a space to force re-emission of identical text. + adapter.events <- announcementEvent("Workspace 2 Mistworld ", 1) + adapter.events <- announcementEvent("Workspace 1 Generic Shit", 1) + + var got []string + for range 2 { + select { + case request := <-speech.requests: + got = append(got, request.Text) + case <-time.After(time.Second): + t.Fatalf("announcements spoken = %v", got) + } + } + select { + case request := <-speech.requests: + t.Fatalf("duplicate announcement spoken: %+v (all: %v)", request, got) + case <-time.After(30 * time.Millisecond): + } + if got[0] != "Workspace 2 Mistworld" || got[1] != "Workspace 1 Generic Shit" { + t.Fatalf("announcements = %v", got) + } +} + +func TestEmptyAnnouncementDoesNotSpeak(t *testing.T) { + speech, adapter, done := announcementFixture(t) + defer done() + + adapter.events <- announcementEvent(" ", 1) + select { + case request := <-speech.requests: + t.Fatalf("empty announcement spoken: %+v", request) + case <-time.After(30 * time.Millisecond): + } +} diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index bfb6adf..6406f81 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -110,6 +110,13 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) { "selection_start", event.Source.SelectionStart, "selection_end", event.Source.SelectionEnd) } + if event.Type == atspi.AnnouncementEvent { + // Announcements are explicit requests to speak from providers that + // may never own focus or the foreground window. Present them before + // any ownership gating, and never let them move focus state. + handler.presentAnnouncement(event) + return + } syncForeground() if handler.handle_window_switcher(event) { return diff --git a/internal/app/event_announcement.go b/internal/app/event_announcement.go new file mode 100644 index 0000000..86d1354 --- /dev/null +++ b/internal/app/event_announcement.go @@ -0,0 +1,46 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// AtspiLive assertive value carried in an announcement event's Detail1. +const announcementAssertive = 2 + +// presentAnnouncement speaks an object:announcement payload, mirroring Orca: +// the provider's text is presented verbatim, assertive announcements +// interrupt, and polite ones append. Announcements commonly come from +// providers that never own focus (a compositor announcing a workspace +// switch, for example), so no focus or foreground ownership is required and +// none is taken. The caller's lock gate has already run; the speech output +// gate still rejects the request while the screen is locked. +func (handler *accessibilityEventHandler) presentAnnouncement(event atspi.Event) { + text := strings.TrimSpace(event.Payload) + if text == "" { + return + } + state := handler.state + // Some providers force re-emission of an unchanged message by altering + // trailing whitespace. Treat one trimmed repeat from the same provider + // inside the live-region dedupe window as the same announcement. + if text == state.lastAnnouncement && event.Source.BusName == state.lastAnnouncementBus && + time.Since(state.lastAnnouncementAt) < 300*time.Millisecond { + return + } + state.lastAnnouncement, state.lastAnnouncementBus, state.lastAnnouncementAt = text, event.Source.BusName, time.Now() + handler.speech.Enqueue(speechRequest{ + Text: text, + Role: "system", + Cause: atspi.AnnouncementEvent, + Append: event.Detail1 != announcementAssertive, + BypassFocusGate: true, + }) +} diff --git a/internal/app/event_state.go b/internal/app/event_state.go index 39ae0c5..77cb2d4 100644 --- a/internal/app/event_state.go +++ b/internal/app/event_state.go @@ -97,6 +97,9 @@ type eventPresentationState struct { lastSound string lastLiveText string lastLiveAt time.Time + lastAnnouncement string + lastAnnouncementBus string + lastAnnouncementAt time.Time comboHistory comboPresentation echoPreview echoPreviewPresentation // Retain this identity after the history window closes so delayed GTK @@ -166,6 +169,7 @@ func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer state.focusCaretID, state.focusCaretSequence = atspi.ObjectID{}, 0 state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", "" state.lastLiveText, state.lastLiveAt = "", time.Time{} + state.lastAnnouncement, state.lastAnnouncementBus, state.lastAnnouncementAt = "", "", time.Time{} state.comboHistory = comboPresentation{} state.echoPreview = echoPreviewPresentation{} state.typingPath, state.typingText, state.suppressNextCaretPath = "", "", "" diff --git a/internal/atspi/adapter.go b/internal/atspi/adapter.go index 245ab0c..bfe721b 100644 --- a/internal/atspi/adapter.go +++ b/internal/atspi/adapter.go @@ -67,6 +67,7 @@ const ( DocumentLoadCompleteEvent = "document:load-complete" DocumentLoadStoppedEvent = "document:load-stopped" BusyStateEvent = "object:state-changed:busy" + AnnouncementEvent = "object:announcement" ) var eventTypes = []string{ @@ -96,6 +97,7 @@ var eventTypes = []string{ DocumentLoadCompleteEvent, DocumentLoadStoppedEvent, BusyStateEvent, + AnnouncementEvent, "object:state-changed:checked", "object:state-changed:expanded", "object:state-changed:indeterminate", diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index 208c5be..ef4356e 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -566,7 +566,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { progressUpdate := snapshot.Role == "progress bar" && (eventType == ValueChangedEvent || eventType == AccessibleValueEvent) inactiveProgress := progressUpdate && !adapter.Foreground().Client.Known && C.cthulhu_in_inactive_window(event.source) != 0 if webNotification == nil && !progressUpdate && !adapter.Foreground().Client.Known && eventType != WindowCreateEvent && eventType != WindowActivatedEvent && - eventType != WindowDeactivatedEvent && eventType != WindowDestroyedEvent && !snapshot.AutocompletePopup && + eventType != WindowDeactivatedEvent && eventType != WindowDestroyedEvent && eventType != AnnouncementEvent && !snapshot.AutocompletePopup && C.cthulhu_in_inactive_window(event.source) != 0 { C.g_free(C.gpointer(payload)) if ownedTarget { diff --git a/internal/atspi/adapter_event_filter_native.go b/internal/atspi/adapter_event_filter_native.go index 094b7ba..064d7f9 100644 --- a/internal/atspi/adapter_event_filter_native.go +++ b/internal/atspi/adapter_event_filter_native.go @@ -17,6 +17,11 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy switch eventType { case WindowCreateEvent, WindowActivatedEvent, WindowDeactivatedEvent, WindowDestroyedEvent, DefunctStateEvent: return false + case AnnouncementEvent: + // An announcement is an explicit request to speak, commonly from a + // provider that never owns focus (for example a compositor's own + // accessible tree). Foreground ownership must not discard it. + return false } foreground := adapter.Foreground() if !foreground.Client.Known || webNotification { diff --git a/internal/atspi/announcement_native_test.go b/internal/atspi/announcement_native_test.go new file mode 100644 index 0000000..789cd6b --- /dev/null +++ b/internal/atspi/announcement_native_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestNativeAnnouncementEventDelivered(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_ANNOUNCEMENT") != "1" { + t.Skip("requires private Xorg/AT-SPI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + + command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_announce.py") + command.Env = append(os.Environ(), "GDK_BACKEND=x11") + input, err := command.StdinPipe() + if err != nil { + t.Fatal(err) + } + output, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { input.Close(); command.Process.Kill(); command.Wait() }) + reader := bufio.NewReader(output) + line, err := reader.ReadString('\n') + if fields := strings.Fields(line); err != nil || len(fields) != 2 || fields[0] != "ready" { + t.Fatalf("fixture readiness: %q, %v", line, err) + } + // Publish the complete AT-SPI cache before announcing. + if _, err := adapter.CaptureDesktop(ctx); err != nil { + t.Fatal(err) + } + + wait := func(text string) Event { + t.Helper() + for { + select { + case event := <-adapter.Events(): + if event.Type == AnnouncementEvent && event.Payload == text { + return event + } + case <-ctx.Done(): + t.Fatalf("announcement %q missing: %v; filtered=%d", text, ctx.Err(), adapter.backgroundEventsFiltered.Load()) + } + } + } + + fmt.Fprintln(input, "announce polite Workspace 2 Fixture") + polite := wait("Workspace 2 Fixture") + t.Logf("polite announcement detail1=%d role=%s", polite.Detail1, polite.Source.Role) + fmt.Fprintln(input, "announce assertive Fixture error") + assertive := wait("Fixture error") + t.Logf("assertive announcement detail1=%d role=%s", assertive.Detail1, assertive.Source.Role) +} diff --git a/internal/atspi/announcement_test.go b/internal/atspi/announcement_test.go new file mode 100644 index 0000000..c0cb41b --- /dev/null +++ b/internal/atspi/announcement_test.go @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import "testing" + +func TestAnnouncementEventIsRegistered(t *testing.T) { + if AnnouncementEvent != "object:announcement" { + t.Fatalf("AnnouncementEvent = %q", AnnouncementEvent) + } + for _, eventType := range eventTypes { + if eventType == AnnouncementEvent { + return + } + } + t.Fatal("object:announcement is not in the registered event types") +} diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 4e1fa53..70690f2 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -71,3 +71,4 @@ edit-field-relation-labels,src/cthulhu/generator.py:_generateLabel;src/cthulhu/s mozilla-hypertext-reading,src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/script_utilities.py,web,Read native heading and link text plus parent text around embedded children without Tab caret echoes interrupting link labels,,internal/atspi internal/review internal/app,implemented,"go test ./... -count=1; go test -race ./internal/review ./internal/atspi ./internal/app -count=1; python3 tests/native_firefox.py; CTHULHU_NATIVE_BROWSER_TESTS=fragments,document,selection,namedlinks python3 tests/native_browser.py",Merge review on 2026-10-20: four parent-text regressions fail against testing before the merge and pass after resolution. Private Firefox baseline and Chromium fragments document selection named-link and editing checks pass.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. The original Thunderbird regression message was not separately identified.,Repeated parent-fragment cursor and native routing offsets tested; physical Braille pending.,Hypertext traversal retains parent offsets around single-character embedded children; named-link labels retain virtual character and word offsets. Password subtrees remain excluded. Read and Tab through an HTML email; reverse direction; verify Firefox and Chromium links selection and address-bar editing before submission. Human diff review and original-message live acceptance remain required. speech-interruption-preferences,src/cthulhu/input_event.py,input,"Choose all keys, common stop keys, or recorded individual interruption keys in Speech preferences",,internal/input internal/config internal/preferences internal/app,implemented,go test ./internal/input ./internal/config ./internal/preferences ./internal/app -run 'Interruption|Interrupt' -count=1,"Private GTK/Xvfb test verifies mode selection, focus traversal, special/modifier/Page Up/multimedia recording, finish/cancel, Apply and draft reload.",Physical compositor acceptance pending.,Maintainer confirms speech preferences; previews; Apply and Cancel; saved settings; and interruption-key settings work in live testing on 2026-10-22. See docs/testing/release-acceptance.md.,Physical display acceptance pending.,All keys remains default. Reader commands retain explicit interruption; unselected keys preserve queued speech and Say All. Control+Enter finishes and Control+Escape cancels without recording the finishing modifier. Settings persist per profile. pidgin-message-history,src/cthulhu/chat.py;src/cthulhu/scripts/apps/pidgin/chat.py;src/cthulhu/scripts/apps/pidgin/script.py,messaging,Announce verified Pidgin transcript insertions and review or copy nine recent messages,Storm Dragon,internal/messaging internal/atspi internal/app internal/input,implemented,go test ./...; go test -race ./internal/messaging ./internal/input ./internal/atspi ./internal/app; see docs/testing/messaging.md for private native checks,Private GTK transcript and X11 command checks pass; actual Pidgin and physical shortcuts pending.,,Recorded presentation tested; actual Pidgin listening pending.,Single flash recorded; physical Braille pending.,"Legacy defaults only: combined per-provider history, all conversations, no room prefix or typing announcements. Legacy chat preference controls remain unported. Cthulhu+H is the sole default learn-mode chord; F1-F9 review and Shift+F1-F9 copy. Existing explicit assignments and modal OCR F5 refresh take precedence. Steam notification history remains separate. See docs/testing/messaging.md." +atspi-announcements,Orca src/orca/script.py;src/orca/scripts/default.py;src/orca/event_manager.py,focus,"Present object:announcement payloads from any provider, politely or assertively, without requiring focus or foreground ownership",,internal/atspi internal/app,implemented,"go test ./internal/atspi ./internal/app -run Announcement -count=1; native GTK4 fixture delivers polite detail1=1 and assertive detail1=2 payloads through the adapter; live Niri 26.04 event capture recorded the workspace-switch announcement shape",Native fixture runs on private Xvfb; Xorg reader behavior otherwise unchanged,"Live Niri 26.04 capture on 2026-10-31: workspace switch emits accessible-name then object:announcement with the workspace label about 10ms before window activation. Maintainer confirms workspace switching, AltTab announcements and the exit dialog read out with the built reader on Niri on 2026-10-31.","Maintainer live listening on Niri passed for workspace switching, AltTab and the exit dialog on 2026-10-31.",Braille flash follows the system-role output queue; physical display acceptance pending.,"Announcements bypass background-event filtering and foreground epochs because the provider explicitly requests speech and may never own focus (a compositor's own accessible tree under Niri via AccessKit). Screen-lock and secure-input gates still apply. Trimmed repeats from one provider inside 300ms are deduped; Niri alternates live-node identities and may append a space to force re-emission. There is no Orca workspace-specific behavior to mirror: Orca only presents the generic announcement. See docs/testing/announcements.md." diff --git a/tests/gtk_announce.py b/tests/gtk_announce.py new file mode 100644 index 0000000..a838317 --- /dev/null +++ b/tests/gtk_announce.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""GTK4 fixture emitting AT-SPI announcement events on stdin commands. + +Commands (one per line): + announce polite + announce assertive +""" +import os +import sys + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("GdkX11", "4.0") +from gi.repository import GdkX11, GLib, Gtk + + +pendingInput = "" + +window = Gtk.Window(title="Announcement fixture") +window.set_default_size(320, 200) +label = Gtk.Label(label="Announcement fixture") +window.set_child(label) +window.present() + + +def receive_command(_source, _condition): + global pendingInput + content = os.read(sys.stdin.fileno(), 4096) + if not content: + loop.quit() + return False + pendingInput += content.decode() + while "\n" in pendingInput: + command, pendingInput = pendingInput.split("\n", 1) + parts = command.split(" ", 2) + if len(parts) == 3 and parts[0] == "announce": + priority = Gtk.AccessibleAnnouncementPriority.MEDIUM + if parts[1] == "assertive": + priority = Gtk.AccessibleAnnouncementPriority.HIGH + window.announce(parts[2], priority) + print("announced", parts[2], flush=True) + return True + + +GLib.io_add_watch(sys.stdin.fileno(), GLib.PRIORITY_DEFAULT, GLib.IO_IN, receive_command) + + +def announce_ready(): + surface = window.get_surface() + xid = GdkX11.X11Surface.get_xid(surface) + print("ready", xid, flush=True) + return False + + +GLib.timeout_add(250, announce_ready) +loop = GLib.MainLoop() +loop.run()