diff --git a/README.md b/README.md index 6697e0c..71af05f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Cthulhu is a Linux screen reader written in Go. - [Messaging support](docs/testing/messaging.md): Pidgin message review/copy shortcuts, application boundaries, and verification. - [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance. - [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification. +- [MATE window switcher](docs/testing/window-switcher.md): Marco selection announcements, isolated checks, and live verification. - [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation. - [Tentacle reader settings API](docs/tentacle-reader-settings.md): contributed preferences pages, quick settings, speech overrides, and verification. diff --git a/docs/testing/window-switcher.md b/docs/testing/window-switcher.md new file mode 100644 index 0000000..161b4c7 --- /dev/null +++ b/docs/testing/window-switcher.md @@ -0,0 +1,66 @@ +# MATE window switcher + +Marco exposes the selected window title as a status-bar accessible. While Alt +is held, the old application can remain the X11 foreground client and the +switcher need not send a focus event. Ordinary foreground and editable-text +filters therefore cannot present its selection. + +Following Orca's switcher event contract, Cthulhu reads the container name on +text insertion, selection change and showing-state gain. The accompanying +accessible-name signal does not repeat the announcement. The native adapter +handles this before routine background filtering and selected-child lookup. +It requires the `marco` process identity, the Marco application name, a known +visible/showing non-defunct status bar and a containing window on the same +provider bus. It refreshes only that accessible before reading the selection. + +The application handler presents the title as transient speech and a Braille +message. It preserves application/native focus, browser mode and review +position, and rejects stale foreground epochs. Terminal handoff permits these +window-manager announcements; screen lock and internal UI still block them. +Normal key interruption and queued speech policy apply. No switcher state, +native references, timers or polling are retained. Other window managers are +outside this change. + +## Automated checks + +```sh +go test ./internal/app -run '^TestMateWindowSwitcher' -count=1 +python3 tests/native_test_session.py env CTHULHU_NATIVE_WINDOW_SWITCHER=1 CTHULHU_NATIVE_BACKGROUND_EVENTS=1 go test -race ./internal/atspi -run '^TestNative(MateWindowSwitcherOutsideForeground|BackgroundEventFiltering)$' -count=1 -timeout=60s -v +``` + +The application regression reproduces the rejected non-focused status event +from the reported log, repeated forward/reverse selections, showing and +selection events, duplicate name rejection, stale/hidden/dead/impostor +rejection, terminal handoff, screen lock and Braille flash requests. A later +name change on the original control verifies that focus ownership survives. + +The private GTK fixture publishes a non-focusable status bar with Marco's +application and process identity. It verifies that repeated insertions and a +showing event survive native background filtering while the editor retains +X11 foreground ownership. It models Marco's AT-SPI contract; it does not run +the real MATE window manager. The existing background flood test checks both +other-provider and same-provider inactive windows and zero content queries. +All native checks use a disposable X server and accessibility bus. + +## Live acceptance + +After reviewing the diff and starting the candidate through the normal local +launcher when ready: + +1. Open at least three differently titled windows. Hold Alt and press Tab + repeatedly. Confirm each selected title is spoken; repeat with Shift+Tab. +2. Release Alt and confirm normal reading follows the chosen application. + Repeat from a terminal, and check that terminal delegation resumes. +3. Open the switcher, cycle through titles and press Escape. Confirm reading + returns to the original control and review/navigation still work. +4. With a browser open, check heading navigation, address-bar editing and + switching between two windows of the same application. Background status + updates must stay quiet. +5. If using Braille, confirm each selection appears as a transient message + and normal application content returns after dismissal. Switcher output + must not appear while the screen is locked. + +Physical keys, audible speech, real Marco cycling/cancellation and physical +Braille remain live acceptance checks. The original log contains an initial +status insertion but no later insertions for its subsequent Tab presses; keep +a fresh timed log if actual cycling still fails. diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index 76fcd51..bfb6adf 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -111,6 +111,9 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) { "selection_end", event.Source.SelectionEnd) } syncForeground() + if handler.handle_window_switcher(event) { + return + } if handler.messaging.handle(event) { return } diff --git a/internal/app/window_switcher.go b/internal/app/window_switcher.go new file mode 100644 index 0000000..39bc16c --- /dev/null +++ b/internal/app/window_switcher.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// A window-manager switcher can leave the old client as X11 foreground while +// Alt is held. Present its selection without replacing that client's focus, +// review position, browser mode or native ownership. No session state is kept. +func (handler *accessibilityEventHandler) handle_window_switcher(event atspi.Event) bool { + if !atspi.IsWindowSwitcherSelection(event) { + return false + } + if handler.focusGate.InternalUIActive() || + handler.foreground != nil && event.Foreground.Epoch != handler.state.foregroundState.Epoch { + return true + } + if title := strings.TrimSpace(event.Source.Name); title != "" { + // The system role also flashes the title on Braille through outputQueue. + // Normal input interruption and FIFO speech policy still apply. + handler.speech.Enqueue(speechRequest{Text: title, Role: "system", Cause: "window-switcher", BypassFocusGate: true}) + } + return true +} diff --git a/internal/app/window_switcher_test.go b/internal/app/window_switcher_test.go new file mode 100644 index 0000000..2b9e7e1 --- /dev/null +++ b/internal/app/window_switcher_test.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +type switcherBrailleRecorder struct{ flashes chan string } + +func (*switcherBrailleRecorder) EnqueueFrame(braille.Frame) {} +func (display *switcherBrailleRecorder) Flash(text string, _ config.BrailleOptions) bool { + display.flashes <- text + return true +} + +func mate_switcher_event(eventType, title string, foreground atspi.ForegroundState) atspi.Event { + event := ownerEvent(eventType, ":1.43", "/switcher", "/switcher/status") + event.Source.ApplicationName, event.Source.ApplicationProcess = "marco", "marco" + event.Source.Role, event.Source.Name = "status bar", title + event.Source.StatesKnown, event.Source.Visible, event.Source.Showing = true, true, true + event.Foreground, event.ForegroundMismatch = foreground, true + return event +} + +func TestMateWindowSwitcherOutsideForeground(t *testing.T) { + adapter := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 32)}, changes: make(chan struct{}, 1)} + foreground := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1} + adapter.state.Store(&foreground) + queue := &fakeSpeechQueue{spoken: make(chan string, 32), requests: make(chan speechRequest, 32)} + live := &liveSettings{} + live.Store(config.DefaultSettings()) + display := &switcherBrailleRecorder{flashes: make(chan string, 16)} + output := outputQueue{speech: queue, braille: display, live: live} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + _ = processEventsWithSettings(ctx, slog.New(slog.DiscardHandler), output, adapter, nil, nil, 0, live) + }() + defer func() { cancel(); <-done }() + + focus := ownerEvent(atspi.FocusedStateEvent, ":1.42", "/editor", "/editor/button") + focus.Source.Role, focus.Source.Name, focus.Source.Focused = "push button", "Save", true + focus.Detail1, focus.Foreground = 1, foreground + adapter.events <- focus + expectActivationSpeech(t, queue.spoken, "Save, push button") + <-queue.requests + + // Replay the log's non-focused status insertion, then repeated forward + // and reverse selection updates without an X11 foreground change. + for _, title := range []string{"Editor", "Terminal", "Editor"} { + adapter.events <- mate_switcher_event(atspi.TextInsertedEvent, title, foreground) + expectActivationSpeech(t, queue.spoken, title) + request := <-queue.requests + if request.Cause != "window-switcher" || request.Role != "system" || !request.BypassFocusGate { + t.Fatalf("switcher request = %+v", request) + } + select { + case got := <-display.flashes: + if got != title { + t.Fatalf("Braille title = %q; want %q", got, title) + } + case <-time.After(time.Second): + t.Fatal("missing switcher Braille message") + } + } + showing := mate_switcher_event(atspi.ShowingStateEvent, "Browser", foreground) + showing.Detail1 = 1 + adapter.events <- showing + expectActivationSpeech(t, queue.spoken, "Browser") + <-queue.requests + adapter.events <- mate_switcher_event(atspi.SelectionChangedEvent, "Terminal", foreground) + expectActivationSpeech(t, queue.spoken, "Terminal") + <-queue.requests + + // Duplicate name signals, hidden/destroyed switchers, stale epochs and + // another application's status bar cannot borrow the exception. + for _, change := range []func(*atspi.Event){ + func(e *atspi.Event) { e.Type = atspi.AccessibleNameEvent }, + func(e *atspi.Event) { e.Type, e.Detail1 = atspi.ShowingStateEvent, 0 }, + func(e *atspi.Event) { e.Source.Role = "label" }, + func(e *atspi.Event) { e.Source.StatesKnown = false }, + func(e *atspi.Event) { e.Source.Visible = false }, + func(e *atspi.Event) { e.Source.Showing = false }, + func(e *atspi.Event) { e.Source.Defunct = true }, + func(e *atspi.Event) { e.Foreground.Epoch-- }, + func(e *atspi.Event) { e.Source.ApplicationName, e.Source.ApplicationProcess = "editor", "editor" }, + func(e *atspi.Event) { e.Source.ApplicationProcess = "python3" }, + func(e *atspi.Event) { e.Window.BusName = ":1.44" }, + func(e *atspi.Event) { e.Source.Name = " " }, + } { + event := mate_switcher_event(atspi.TextInsertedEvent, "Unwanted output", foreground) + change(&event) + adapter.events <- event + } + // A name change on the original control proves that switcher output did + // not replace application ownership or the locus of focus. FIFO provides + // a barrier for every rejected event above. + focus.Type, focus.Source.Name = atspi.AccessibleNameEvent, "Still focused" + adapter.events <- focus + expectActivationSpeech(t, queue.spoken, "Still focused, push button") +} + +func TestMateWindowSwitcherTerminalHandoffAndLock(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 4)} + gate := &focusGate{} + gate.suspended.Store(true) + handler := accessibilityEventHandler{focusGate: gate, speech: gatedSpeech{speech: queue, gate: gate}} + event := mate_switcher_event(atspi.TextInsertedEvent, "Editor", atspi.ForegroundState{}) + if !handler.handle_window_switcher(event) { + t.Fatal("switcher selection was not handled") + } + expectActivationSpeech(t, queue.spoken, "Editor") + // The top-level accessibility handler must reject the event before the + // special path, even though terminal-handoff output is allowed above. + gate.secure.Store(true) + handler.handle(event) + if len(queue.spoken) != 0 { + t.Fatal("switcher selection spoke during screen lock") + } +} diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index ed10378..208c5be 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -433,6 +433,10 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { adapter.forgetWindowDocuments(originID) } invalidated := adapter.applyTreeInvalidation(eventType, event) + if selection := adapter.window_switcher_event(event, eventType); selection != nil { + adapter.enqueueSecureEvent(*selection, secureEpoch) + return + } if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 && adapter.applicationProcesses[originID.BusName] == "pidgin" { // Read-only transcripts need not own native focus. Forward their diff --git a/internal/atspi/adapter_window_switcher_native.go b/internal/atspi/adapter_window_switcher_native.go new file mode 100644 index 0000000..47733be --- /dev/null +++ b/internal/atspi/adapter_window_switcher_native.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +// Capture only the verified window manager's visible status container, before +// background filtering or selected-child resolution. All native queries run +// on the GLib owner; no native references or mutable switcher state survive. +func (adapter *Adapter) window_switcher_event(event *C.AtspiEvent, eventType string) *Event { + detail1 := int(C.cthulhu_event_detail1(event)) + if !is_window_switcher_selection_type(eventType, detail1) || + C.atspi_accessible_get_role(event.source, nil) != C.ATSPI_ROLE_STATUS_BAR || + adapter.applicationProcess(event.source) != "marco" { + return nil + } + // Insertions can precede accessible-name updates. Read the current name + // and state instead of announcing the previous selection from the cache. + C.atspi_accessible_clear_cache_single(event.source) + snapshot := snapshotFromNativeWithCaret(event.source, false, false, false, false) + snapshot.ApplicationProcess = "marco" + selection := NewEvent(eventType, snapshot) + selection.Detail1 = detail1 + selection.Foreground = adapter.Foreground() + selection.ForegroundMismatch = true + window := C.cthulhu_window_ancestor(event.source) + if window == nil { + return nil + } + selection.Window = nativeObjectID(window) + C.g_object_unref(C.gpointer(window)) + if !IsWindowSwitcherSelection(selection) { + return nil + } + return &selection +} diff --git a/internal/atspi/window_switcher.go b/internal/atspi/window_switcher.go new file mode 100644 index 0000000..4a49290 --- /dev/null +++ b/internal/atspi/window_switcher.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import "strings" + +func is_window_switcher_selection_type(eventType string, detail1 int) bool { + switch eventType { + case TextInsertedEvent, SelectionChangedEvent: + return true + case ShowingStateEvent: + return detail1 != 0 + default: + return false + } +} + +// IsWindowSwitcherSelection matches Marco's transient selection container. +// Like Orca's switcher script, it uses insert/show/selection events and the +// container's name. The following accessible-name signal is not another move. +// Process identity and visible state keep ordinary background status updates +// out of this exception. This does not grant native or application focus. +func IsWindowSwitcherSelection(event Event) bool { + source := event.Source + return is_window_switcher_selection_type(event.Type, event.Detail1) && + source.ApplicationProcess == "marco" && strings.EqualFold(source.ApplicationName, "marco") && + source.Role == "status bar" && source.StatesKnown && source.Visible && source.Showing && !source.Defunct && + source.ID().Valid() && event.Window.Valid() && event.Window.BusName == source.BusName +} diff --git a/internal/atspi/window_switcher_native_test.go b/internal/atspi/window_switcher_native_test.go new file mode 100644 index 0000000..c233397 --- /dev/null +++ b/internal/atspi/window_switcher_native_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestNativeMateWindowSwitcherOutsideForeground(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_WINDOW_SWITCHER") != "1" { + t.Skip("requires private Xorg/AT-SPI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + start := func(name string) (io.WriteCloser, string) { + command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_window_switcher.py", name) + 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() }) + line, err := bufio.NewReader(output).ReadString('\n') + fields := strings.Fields(line) + if err != nil || len(fields) != 2 || fields[0] != "ready" { + t.Fatalf("fixture readiness: %q, %v", line, err) + } + return input, fields[1] + } + switcher, _ := start("marco") + foreground, xid := start("editor") + if _, err := adapter.CaptureDesktop(ctx); err != nil { + t.Fatal(err) + } + if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", xid).CombinedOutput(); err != nil { + t.Fatalf("focus: %s, %v", output, err) + } + wait := func(eventType, title string, switcher bool) Event { + t.Helper() + var last Event + count := 0 + for { + select { + case event := <-adapter.Events(): + last = event + count++ + if event.Type == eventType && (event.Source.Name == title || event.Payload == title) { + if IsWindowSwitcherSelection(event) != switcher { + t.Fatalf("switcher classification: %+v", event) + } + return event + } + case <-ctx.Done(): + t.Fatalf("missing %s %q; background-filtered=%d received=%d last=%+v", eventType, title, adapter.backgroundEventsFiltered.Load(), count, last) + } + } + } + fmt.Fprintln(foreground, "select Foreground ready") + owned := wait(TextInsertedEvent, "Foreground ready", false) + if owned.ForegroundMismatch || !owned.Foreground.Client.Known { + t.Fatalf("fixture has no correlated foreground: %+v", owned) + } + for _, title := range []string{"Editor", "Terminal", "Editor"} { + fmt.Fprintln(switcher, "select "+title) + event := wait(TextInsertedEvent, title, true) + if !event.ForegroundMismatch || event.Foreground != owned.Foreground || event.Source.Focused { + t.Fatalf("switcher acquired foreground/native focus: %+v", event) + } + } + fmt.Fprintln(switcher, "showing") + wait(ShowingStateEvent, "Editor", true) + // The provider stays separate from the old foreground after the popup + // disappears. Ordinary application events still use native correlation. + fmt.Fprintln(switcher, "hide") + fmt.Fprintln(foreground, "select Foreground retained") + retained := wait(TextInsertedEvent, "Foreground retained", false) + if retained.ForegroundMismatch || retained.Window != owned.Window { + t.Fatalf("switcher changed foreground ownership: %+v", retained) + } +} diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index dbe1168..8fad7a8 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -1,4 +1,5 @@ id,python_source,subsystem,behavior,owner,go_destination,status,automated_evidence,x11_evidence,wayland_evidence,speech_evidence,braille_evidence,notes +mate-window-switcher,Orca src/orca/scripts/switcher/script.py;src/orca/scripts/switcher/script_utilities.py,focus,Present Marco switcher selections while the old application retains X11 foreground,,internal/atspi internal/app,in-progress,go test ./internal/app -run '^TestMateWindowSwitcher' -count=1; see docs/testing/window-switcher.md,Private GTK provider checks repeated insertions and showing events outside foreground without taking ownership.,,Actual Marco listening and physical key acceptance pending.,Transient flash requests tested; physical display acceptance pending.,Verified Marco process and application plus visible status-bar container only. Preserves application focus and review. No name-signal duplication or general background bypass. Original log lacks later cycling insertions; real cycling and Escape remain live gates. gtk-alert-message,src/cthulhu/speech_generator.py;src/cthulhu/script_utilities.py,speech,Read a GTK alert message before its first focused button,,internal/atspi internal/app,in-progress,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private GTK/Xvfb/AT-SPI fixture captured the complete question on 2026-09-26.,,Physical FastSMRW listening pending.,,Alert window creation captures its label text; activation does not interrupt the message; the first button appends and later buttons use ordinary focus speech. yad-simple-prompt,src/cthulhu/formatting.py;src/cthulhu/scripts/default.py,speech,Read a simple YAD frame question before its first focused button,,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private YAD/Xvfb/AT-SPI fixture captured the exact question on frame activation or first button focus. The live 2026-09-27 log showed a third path where activation targets the focused button; an event-loop regression covers its speech order.,,Maintainer confirmed the YAD question was spoken on the updated build on 2026-09-27.,,YAD exposes the prompt as a frame with layout nodes and a label plus buttons. The generic Python frame format appears to omit the label; this Go addition follows the user's explicit example. A strict simple prompt shape limits frame text capture and bounded first-button refresh handles late labels. radio-group-focus,src/cthulhu/generator.py;src/cthulhu/speech_generator.py,speech,Speak the radio group question with a focused option,,internal/atspi internal/presentation internal/preferences,implemented,go test ./internal/presentation ./internal/atspi ./internal/preferences -count=1; python3 tests/native_alert.py,Private GTK radio group focus captured Punctuation Level.,,Physical preferences speech pending.,,A named panel or labelled-by relation supplies the group question; each focused radio presents that name before its option. The Braille verbosity radios now have a named frame. diff --git a/tests/gtk_window_switcher.py b/tests/gtk_window_switcher.py new file mode 100644 index 0000000..07c2d4d --- /dev/null +++ b/tests/gtk_window_switcher.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 +"""A non-focusable status container in a private Marco-shaped GTK provider.""" +import ctypes +import os +import sys + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Atk", "1.0") +gi.require_version("GdkX11", "3.0") +from gi.repository import Atk, GdkX11, GLib, Gtk + + +providerName = sys.argv[1] +GLib.set_prgname(providerName) +# Match /proc/PID/comm as well as the AT-SPI name; a name alone must not +# enable the window-manager exception. +if providerName == "marco": + if ctypes.CDLL(None).prctl(15, b"marco", 0, 0, 0) != 0: + raise RuntimeError("cannot set fixture process name") + +window = Gtk.Window(title="Switcher fixture " + providerName) +if providerName == "marco": + window.set_accept_focus(False) + window.set_focus_on_map(False) +label = Gtk.Label(label="Initial selection") +label.get_accessible().set_role(Atk.Role.STATUSBAR) +window.add(label) +window.show_all() +Atk.get_root().set_name(providerName) +pendingInput = "" + + +def receive_command(_source, _condition): + global pendingInput + content = os.read(sys.stdin.fileno(), 4096) + if not content: + Gtk.main_quit() + return False + pendingInput += content.decode() + while "\n" in pendingInput: + command, pendingInput = pendingInput.split("\n", 1) + if command.startswith("select "): + label.set_text(command.removeprefix("select ")) + elif command == "showing": + label.get_accessible().notify_state_change(Atk.StateType.SHOWING, True) + elif command == "hide": + label.hide() + elif command == "quit": + Gtk.main_quit() + return False + return True + + +GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, receive_command) +GLib.idle_add(lambda: print("ready", GdkX11.X11Window.get_xid(window.get_window()), flush=True) or False) +Gtk.main()