diff --git a/README.md b/README.md index 3d37b87..18a45e2 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Cthulhu is a Linux screen reader written in Go. - [Contributing](CONTRIBUTING.md): development, accessibility verification, licensing, and the AI-assisted contribution policy. - [LibreOffice support](LIBREOFFICE_SUPPORT.md): integration boundaries, initial Calc behavior, remaining work, and verification. - [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification. +- [Steam keyboard focus](STEAM_FOCUS.md): Xorg workspace focus recovery and verification. AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions). diff --git a/STEAM_FOCUS.md b/STEAM_FOCUS.md new file mode 100644 index 0000000..37e0562 --- /dev/null +++ b/STEAM_FOCUS.md @@ -0,0 +1,67 @@ +# Steam keyboard focus on Xorg + +Steam Big Picture can retain its accessible focus while physical keyboard +events reach the outer Steam window instead of its embedded Chromium window. +In the reported game/workspace round trip, Escape stopped opening or closing +the menu, and switching browse/focus mode did not help. A delivery trace showed +both Escape edges reaching the outer window with no held modifiers. Restoring +native focus to the browser child recovered the open menu without restarting +Steam or Cthulhu. The original trigger remains unverified. + +Cthulhu's Xorg command owner remembers the most recently focused, mapped +browser child of a Steam webhelper window. During the first second after that +window becomes foreground again, it allows Steam to restore its own focus. +Only if two consecutive focus samples still identify the outer window does it +attempt to restore the remembered child. A successful repair ends the attempt +for that activation. + +The repair verifies the foreground window, Steam window class, process IDs, +child ancestry, and mapping state immediately before changing focus. It leaves +valid inner focus alone, including menus, search fields, and dialogs. It does +not run with keys held, reader modal input active, OCR active or pending, or +secure input active. Hidden, destroyed, unrelated, or unverified targets cause +it to leave focus unchanged. It does not guess another child or retain a +hard-coded window ID. The cache holds one Steam window and expires with the +command listener; restarting Cthulhu in an already broken session may require +fresh Steam focus before a valid child can be remembered. + +This is an Xorg Steam workaround. Other applications and native Wayland focus +handling do not receive automatic keyboard-focus changes. + +## Verification + +The policy tests cover activation grace, remembered popup focus, bounded +attempts, process/window boundaries, and preservation of valid browser focus. +The native fixture verifies real Escape press/release delivery after repair and +rejects hidden or destroyed popups, unrelated children, changed process IDs, +held modifiers, other applications, and ordinary window classes. + +```sh +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/platform/xorg ./internal/app -count=1 +xvfb-run -a env CTHULHU_NATIVE_STEAM_FOCUS=1 GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test ./internal/platform/xorg -run '^TestNativeSteamFocusRepair$' -count=1 -v +CTHULHU_NATIVE_BROWSER_TESTS=keys,modes python3 tests/native_browser.py +python3 tests/native_x11.py +``` + +These checks passed for the initial patch. The existing native browser editing +check failed on both the patch and unchanged commit `9205e72`: its first typed +`c` was accompanied by `Cthulhu Browser Probe - Brave`. That separate title +announcement failure was not changed here. + +Human review and live Steam acceptance remain required before submission: + +1. Restart Cthulhu using the normal `cthulhu-go.sh -r` launcher, then start fresh + Steam Big Picture so the reader can observe a valid browser focus. +2. Verify Escape and game activation initially work. Launch a game, switch to + another workspace and back, and verify Escape and game activation again. +3. Repeat with the Steam menu open before switching away. Check Escape closes + it and arrows/Enter still select commands. +4. Type in Steam search, use Tab and Shift+Tab, and move away/back while search + or a dialog has focus. Check text entry, selection, and dialog buttons retain + their intended focus. +5. Switch to a normal browser and a GTK application. Check native typing, + browser structural navigation, and Cthulhu's XTerm/Fenrir handoff. Quit + Cthulhu and confirm normal application keys remain available. + +Do not count a native fixture pass as audible speech, physical Braille, or +successful operation of Steam's actual Chromium/controller navigation. diff --git a/internal/app/document_native_test.go b/internal/app/document_native_test.go index ca0c371..f68ba18 100644 --- a/internal/app/document_native_test.go +++ b/internal/app/document_native_test.go @@ -282,11 +282,18 @@ func TestNativeBrowserEditing(t *testing.T) { } }() defer func() { cancel(); <-relayDone }() - queue := &fakeSpeechQueue{spoken: make(chan string, 128)} + queue := &fakeSpeechQueue{spoken: make(chan string, 128), interrupt: make(chan struct{}, 128)} + logger := slog.New(slog.DiscardHandler) + inputDone := make(chan struct{}) + go func() { + defer close(inputDone) + processInputEvents(ctx, logger, queue, listener, input.EchoSettings{}, nil, nil) + }() + defer func() { cancel(); <-inputDone }() done := make(chan struct{}) go func() { defer close(done) - processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, observed, nil, nil, 20*time.Millisecond, nil, listener, nil, nil) + processEventsWithInput(ctx, logger, queue, observed, nil, nil, 20*time.Millisecond, nil, listener, nil, nil) }() defer func() { cancel(); <-done }() keys := func(chord string) { @@ -315,11 +322,17 @@ func TestNativeBrowserEditing(t *testing.T) { keys("ctrl+l") collect() for _, letter := range []string{"c", "t", "h"} { + for len(queue.interrupt) > 0 { + <-queue.interrupt + } keys(letter) got := collect() if len(got) != 1 || got[0] != letter { t.Fatalf("typing %q: %q", letter, got) } + if len(queue.interrupt) == 0 { + t.Fatalf("typing %q did not interrupt speech", letter) + } } keys("Down") got := collect() @@ -380,9 +393,13 @@ func TestNativeBrowserEditing(t *testing.T) { if len(got) != 1 || got[0] != "• Registered: September 2026" { t.Fatalf("embedded browser line text: %q", got) } + // Preserve the browser-specific suffix. AT-SPI can report this change + // before the native X11 title cache catches up. + wantTitle := strings.Replace(adapter.ForegroundTitle().Title.Title, "Cthulhu Browser Probe", "Cthulhu Browser Probe updated", 1) keys("F8") - if got := collect(); len(got) != 0 || !browserNameChanged.Load() { - t.Fatalf("browser title update: speech=%q, native event=%v", got, browserNameChanged.Load()) + got = collect() + if len(got) != 1 || got[0] != wantTitle || !strings.Contains(wantTitle, "Cthulhu Browser Probe updated") || !browserNameChanged.Load() { + t.Fatalf("browser title update: speech=%q, expected=%q, native event=%v", got, wantTitle, browserNameChanged.Load()) } // Seed a typed browser URL locally, then replace it with a prefix. This diff --git a/internal/app/event_loop.go b/internal/app/event_loop.go index 7f7b8b9..68426cf 100644 --- a/internal/app/event_loop.go +++ b/internal/app/event_loop.go @@ -151,7 +151,7 @@ func processEventsWithAttributes( foregroundChanges = foreground.ForegroundChanges() state.foregroundState = foreground.Foreground() } - titles := &windowTitleController{state: &state, live: live, gate: focusGate, speech: speech, logger: logger, foreground: foreground} + titles := &windowTitleController{state: &state, live: live, gate: focusGate, speech: speech, logger: logger, foreground: foreground, inputSource: inputSource} titles.source, _ = adapter.(windowTitleSource) var titleChanges <-chan struct{} if titles.source != nil { diff --git a/internal/app/speech_title_input.go b/internal/app/speech_title_input.go index e721beb..0a8874b 100644 --- a/internal/app/speech_title_input.go +++ b/internal/app/speech_title_input.go @@ -19,6 +19,5 @@ func (policy *titleInputPolicy) observe(request speechRequest) { } func (policy *titleInputPolicy) preserves(event input.Event) bool { - return policy.protectRepeats && event.Repeat && event.Pressed && - event.Name != "Control_L" && event.Name != "Control_R" && event.Name != "Escape" + return policy.protectRepeats && event.Pressed && !input.ShouldInterruptTitle(event) } diff --git a/internal/app/window_titles.go b/internal/app/window_titles.go index 15cbc65..c06ce98 100644 --- a/internal/app/window_titles.go +++ b/internal/app/window_titles.go @@ -20,13 +20,14 @@ type windowTitleSource interface { } type pendingWindowTitle struct { - text string - window atspi.ObjectID - epoch uint64 - native xorg.WindowTitle - changed bool - application string - observedAt time.Time + text string + window atspi.ObjectID + epoch uint64 + native xorg.WindowTitle + changed bool + application string + observedAt time.Time + inputSequence uint64 } // windowTitleController is owned by the accessibility event loop. Discovery @@ -40,6 +41,7 @@ type windowTitleController struct { logger *slog.Logger source windowTitleSource foreground foregroundMonitor + inputSource any lastNative atspi.ForegroundTitle lastTitle string application string @@ -96,6 +98,11 @@ func (titles *windowTitleController) applicationForEvent(event atspi.Event) stri func (titles *windowTitleController) queue(pending pendingWindowTitle) { if pending.observedAt.IsZero() { pending.observedAt = time.Now() + pending.inputSequence = recentInput(titles.inputSource).TitleInterruptionSequence + } + if titles.pending != nil { + // Coalescing must not revive a burst interrupted since its first sample. + pending.inputSequence = titles.pending.inputSequence } if previous := titles.pending; previous != nil && !previous.changed && previous.epoch == pending.epoch && previous.window == pending.window && previous.native.Window == pending.native.Window && @@ -246,6 +253,9 @@ func (titles *windowTitleController) flush() { return } titles.lastTitle = pending.text + if pending.inputSequence != recentInput(titles.inputSource).TitleInterruptionSequence { + return + } if titles.logger != nil { titles.logger.Debug("queued automatic window title", "title_changed", pending.changed, "observed_to_queue_ms", time.Since(pending.observedAt).Milliseconds()) diff --git a/internal/app/window_titles_native_test.go b/internal/app/window_titles_native_test.go index 176c490..ada31bc 100644 --- a/internal/app/window_titles_native_test.go +++ b/internal/app/window_titles_native_test.go @@ -15,6 +15,8 @@ import ( "time" "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) func TestNativeAutomaticTitleDispatchLatency(t *testing.T) { @@ -46,9 +48,22 @@ func TestNativeAutomaticTitleDispatchLatency(t *testing.T) { t.Fatal(err) } defer adapter.Stop(context.Background()) + listener := xorg.New() + if err = listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + inputDone := make(chan struct{}) + go func() { + defer close(inputDone) + processInputEvents(ctx, slog.New(slog.DiscardHandler), queue, listener, input.EchoSettings{}, nil, nil) + }() + defer func() { cancel(); <-inputDone }() completed := make(chan error, 1) - go func() { completed <- processEvents(ctx, slog.New(slog.DiscardHandler), queue, adapter, 0) }() + go func() { + completed <- processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, listener, nil, nil) + }() defer func() { cancel() if err := <-completed; err != nil { @@ -60,9 +75,10 @@ func TestNativeAutomaticTitleDispatchLatency(t *testing.T) { for { select { case got := <-queue.spoken: - if got == want { - return + if got != want { + t.Fatalf("title = %q, want %q", got, want) } + return case <-deadline: t.Fatalf("title %q was not queued promptly", want) case <-ctx.Done(): @@ -84,4 +100,26 @@ func TestNativeAutomaticTitleDispatchLatency(t *testing.T) { wait(title, deadline) t.Logf("X11 title update to speech queue: %s", time.Since(began)) } + // The switch chord must not suppress the destination's introduction. The + // fixture has no window manager, so activate each destination explicitly. + for _, destination := range []struct{ window, title string }{ + {"unrelated", "Unrelated title"}, {"normal", "Status three"}, + } { + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "alt+Tab").CombinedOutput(); err != nil { + t.Fatalf("switch keys: %v %s", err, out) + } + data, _ := json.Marshal(map[string]string{"action": "activate", "window": destination.window}) + if _, err = stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + if !scanner.Scan() || scanner.Text() != "ready" { + t.Fatal("fixture switch failed") + } + wait(destination.title, time.After(2*time.Second)) + } + select { + case got := <-queue.spoken: + t.Fatalf("duplicate window title: %q", got) + case <-time.After(2 * windowTitleIntroductionDelay): + } } diff --git a/internal/app/window_titles_test.go b/internal/app/window_titles_test.go index b1de46d..ee91500 100644 --- a/internal/app/window_titles_test.go +++ b/internal/app/window_titles_test.go @@ -13,6 +13,7 @@ import ( "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/platform/xorg" "git.stormux.org/storm/cthulhu/internal/preferences" ) @@ -80,6 +81,90 @@ func TestAutomaticTitlePolicyCoalescesAndPreservesSpeech(t *testing.T) { } } +func TestScheduledTitleInterruptedBeforeSpeechQueue(t *testing.T) { + for _, name := range []string{"c", "Tab", "Control_L", "Escape"} { + t.Run(name, func(t *testing.T) { + titles, queue, changed := titleControllerFixture(t) + tracker := &input.IntentTracker{} + titles.inputSource = tracker + changed.Source.Name = "Scheduled status" + titles.accessible(changed) + tracker.Observe(input.NewKeyEvent(0, true, name)) + // A newer sample in the same burst must not undo cancellation. + changed.Source.Name = "Latest status" + titles.accessible(changed) + titles.flush() + if len(queue.requests) != 0 || titles.pending != nil { + t.Fatal("interrupted title reached the speech queue") + } + // A genuinely later change still announces normally. + changed.Source.Name = "New status" + titles.accessible(changed) + titles.flush() + if len(queue.requests) != 1 || queue.requests[0].Text != "New status" { + t.Fatalf("later title = %+v", queue.requests) + } + }) + } +} + +func TestScheduledNativeIntroductionPreservesSwitchAndRepeatInput(t *testing.T) { + titles, queue, _ := titleControllerFixture(t) + titles.state.owner.clear() + tracker := &input.IntentTracker{} + titles.inputSource = tracker + source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 20, TitleWindow: 20, Title: "Other window"}}} + titles.source, titles.foreground = source, source + // Input initiating a switch precedes discovery of its destination title. + tracker.Observe(input.NewKeyEvent(0, true, "Tab")) + titles.nativeChanged() + repeat := input.NewKeyEvent(0, true, "Tab") + repeat.Repeat = true + tracker.Observe(repeat) + titles.flush() + if len(queue.requests) != 1 || queue.requests[0].Text != "Other window" { + t.Fatalf("destination title = %+v", queue.requests) + } + // Fresh typing after a subsequent switch cancels its delayed introduction. + source.title.Epoch++ + source.title.Title.Window, source.title.Title.TitleWindow = 21, 21 + source.title.Title.Title = "Next window" + titles.nativeChanged() + tracker.Observe(input.NewKeyEvent(0, true, "c")) + repeat = input.NewKeyEvent(0, true, "c") + repeat.Repeat = true + tracker.Observe(repeat) + titles.flush() + if len(queue.requests) != 1 { + t.Fatal("typing interruption was hidden by its later repeat") + } + // Returning to that window must introduce it again despite cancellation. + source.title.Epoch++ + titles.nativeChanged() + titles.flush() + if len(queue.requests) != 2 || queue.requests[1].Text != "Next window" { + t.Fatalf("return title = %+v", queue.requests) + } +} + +func TestScheduledTitleCannotReviveWhileWaitingForSettings(t *testing.T) { + titles, queue, changed := titleControllerFixture(t) + tracker := &input.IntentTracker{} + titles.inputSource = tracker + titles.live.onApplication = func(string) {} + titles.application = "Example Application" + changed.Source.Name = "Pending application title" + titles.accessible(changed) + titles.flush() // Requeued until the application override is committed. + tracker.Observe(input.NewKeyEvent(0, true, "c")) + application := titles.application + titles.live.application.Store(&application) + titles.flush() + if len(queue.requests) != 0 || titles.pending != nil { + t.Fatal("settings retry revived an interrupted title") + } +} + func TestAutomaticTitleUsesBrailleMessagePathAndKeepsFocus(t *testing.T) { titles, queue, changed := titleControllerFixture(t) display := &flashRecorder{} diff --git a/internal/input/input.go b/internal/input/input.go index 609cae8..f8740c2 100644 --- a/internal/input/input.go +++ b/internal/input/input.go @@ -123,3 +123,9 @@ func (modifiers ModifierKeycodes) Contains(keycode uint8) bool { func ShouldInterruptSpeech(event Event) bool { return event.Pressed } + +// Automatic titles survive held-key repeats, but fresh input and explicit +// stop keys cancel both scheduled and already queued title speech. +func ShouldInterruptTitle(event Event) bool { + return event.Pressed && (!event.Repeat || event.Name == "Control_L" || event.Name == "Control_R" || event.Name == "Escape") +} diff --git a/internal/input/intent.go b/internal/input/intent.go index 8b57101..f545dac 100644 --- a/internal/input/intent.go +++ b/internal/input/intent.go @@ -24,10 +24,13 @@ const ( ) type Activity struct { - Selecting bool - Intent Intent - Sequence uint64 - Clipboard ClipboardAction + // Unlike presentation intent, this counter never expires. Deferred titles + // may be waiting for application settings after ordinary input has aged out. + TitleInterruptionSequence uint64 + Selecting bool + Intent Intent + Sequence uint64 + Clipboard ClipboardAction } type ClipboardAction uint8 @@ -40,13 +43,14 @@ const ( ) type IntentTracker struct { - sequence uint64 - mutex sync.Mutex - held map[string]bool - intent Intent - clipboard ClipboardAction - selecting bool - at time.Time + titleInterruptionSequence uint64 + sequence uint64 + mutex sync.Mutex + held map[string]bool + intent Intent + clipboard ClipboardAction + selecting bool + at time.Time } func (tracker *IntentTracker) Observe(event Event) { @@ -55,6 +59,9 @@ func (tracker *IntentTracker) Observe(event Event) { if event.Pressed { tracker.sequence++ } + if ShouldInterruptTitle(event) { + tracker.titleInterruptionSequence++ + } if tracker.held == nil { tracker.held = make(map[string]bool) } @@ -129,7 +136,8 @@ func (tracker *IntentTracker) RecentInput() Activity { tracker.mutex.Lock() defer tracker.mutex.Unlock() if time.Since(tracker.at) > time.Second { - return Activity{} + return Activity{TitleInterruptionSequence: tracker.titleInterruptionSequence} } - return Activity{Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting} + return Activity{Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting, + TitleInterruptionSequence: tracker.titleInterruptionSequence} } diff --git a/internal/input/intent_test.go b/internal/input/intent_test.go index 8a5b3fa..a801360 100644 --- a/internal/input/intent_test.go +++ b/internal/input/intent_test.go @@ -83,3 +83,33 @@ func TestSelectionIntentEndsWithPlainNavigation(t *testing.T) { t.Fatalf("Right = %+v", got) } } + +func TestTitleInterruptionTracksFreshPressesAndStopKeys(t *testing.T) { + tracker := &IntentTracker{} + for _, step := range []struct { + name string + pressed bool + repeat bool + want uint64 + }{ + {"a", true, false, 1}, {"a", false, false, 1}, + {"a", true, true, 1}, {"Down", true, false, 2}, + {"Down", true, true, 2}, {"Control_L", true, true, 3}, + {"Control_R", true, true, 4}, {"Escape", true, true, 5}, + } { + event := NewKeyEvent(0, step.pressed, step.name) + event.Repeat = step.repeat + tracker.Observe(event) + if got := tracker.RecentInput().TitleInterruptionSequence; got != step.want { + t.Fatalf("%+v: title interruption sequence = %d", step, got) + } + } + tracker.at = time.Now().Add(-2 * time.Second) + if got := tracker.RecentInput(); got.Intent != NoIntent || got.TitleInterruptionSequence != 5 { + t.Fatalf("expired activity = %+v", got) + } + tracker.Reset() + if got := tracker.RecentInput().TitleInterruptionSequence; got != 5 { + t.Fatalf("reset lost interruption history: %d", got) + } +} diff --git a/internal/platform/xorg/commands_native.h b/internal/platform/xorg/commands_native.h index 5eacfd3..01eba64 100644 --- a/internal/platform/xorg/commands_native.h +++ b/internal/platform/xorg/commands_native.h @@ -30,11 +30,14 @@ typedef struct { unsigned long window; } Foreground; typedef struct { int known, pid, x, y; unsigned long window; } PointerPosition; +typedef struct { int known, pid, steam, innerFocus; Window window, focus; } SteamFocusSample; int cthulhu_active_window_is_xterm(void); int cthulhu_display_active_window_xterm_match(Display *display); Foreground read_foreground(Display *display); PointerPosition cthulhu_read_pointer(Display *display); +SteamFocusSample cthulhu_read_steam_focus(Display *display); +int cthulhu_restore_steam_focus(Display *display, Window wrapper, int pid, Window target); void cthulhu_x11_begin_read(Display *display); int cthulhu_x11_end_read(Display *display); int set_command_keys(CommandDevice *device, int enabled); diff --git a/internal/platform/xorg/commands_owner_loop.go b/internal/platform/xorg/commands_owner_loop.go index 6f72d16..f9566ab 100644 --- a/internal/platform/xorg/commands_owner_loop.go +++ b/internal/platform/xorg/commands_owner_loop.go @@ -38,6 +38,7 @@ type commandOwner struct { uiWindow, uiFocus C.Window lastLocks, lastFocusMatch C.int lastFocusCheck, drainDeadline time.Time + steamFocus steamFocusRepair } func (listener *CommandListener) runCommandLoop(workerContext context.Context, device *C.CommandDevice, layout input.KeyboardLayout, modifiers []string, core, tentacles []input.BindingOverride, symbols []uint32) { @@ -72,6 +73,7 @@ func (owner *commandOwner) run() { } if time.Since(owner.lastFocusCheck) >= xtermFocusCheckInterval { owner.publishFocus() + owner.check_steam_focus() } owner.resetForSecureInput() if !owner.syncGrabs() { diff --git a/internal/platform/xorg/steam_focus.c b/internal/platform/xorg/steam_focus.c new file mode 100644 index 0000000..f76a65e --- /dev/null +++ b/internal/platform/xorg/steam_focus.c @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "commands_native.h" +#include +#include +#include + +static int steam_active_window(Display *display, Window *window) { + Atom type; int format; + unsigned long count, remaining; + unsigned char *data = NULL; + int known = XGetWindowProperty(display, DefaultRootWindow(display), XInternAtom(display, "_NET_ACTIVE_WINDOW", False), + 0, 1, False, XA_WINDOW, &type, &format, &count, &remaining, &data) == Success && + data != NULL && type == XA_WINDOW && format == 32 && count == 1; + if (known) *window = ((Window *)data)[0]; + if (data != NULL) XFree(data); + return known; +} + +static int steam_window_pid(Display *display, Window window) { + Atom type; int format; + unsigned long count, remaining; + unsigned char *data = NULL; + int pid = 0; + if (XGetWindowProperty(display, window, XInternAtom(display, "_NET_WM_PID", False), + 0, 1, False, XA_CARDINAL, &type, &format, &count, + &remaining, &data) == Success && data != NULL && + type == XA_CARDINAL && format == 32 && count == 1) { + pid = (int)((unsigned long *)data)[0]; + } + if (data != NULL) XFree(data); + return pid; +} + +static int steam_wrapper(Display *display, Window window) { + XClassHint hint = {0}; + int matches = XGetClassHint(display, window, &hint) && + hint.res_name != NULL && hint.res_class != NULL && + strcmp(hint.res_name, "steamwebhelper") == 0 && strcmp(hint.res_class, "steam") == 0; + if (hint.res_name != NULL) XFree(hint.res_name); + if (hint.res_class != NULL) XFree(hint.res_class); + return matches; +} + +static int steam_browser_child(Display *display, Window wrapper, int pid, Window child) { + XWindowAttributes attributes; + if (child == None || child == PointerRoot || child == wrapper || + !XGetWindowAttributes(display, child, &attributes) || attributes.map_state != IsViewable || + attributes.class != InputOutput || steam_window_pid(display, child) != pid) return 0; + for (int depth = 0; depth < 32; depth++) { + Window root, parent, *children = NULL; + unsigned int count; + if (!XQueryTree(display, child, &root, &parent, &children, &count)) return 0; + if (children != NULL) XFree(children); + if (parent == wrapper) return 1; + if (parent == None || parent == root) return 0; + child = parent; + } + return 0; +} + +SteamFocusSample cthulhu_read_steam_focus(Display *display) { + SteamFocusSample sample = {0}; + cthulhu_x11_begin_read(display); + sample.known = steam_active_window(display, &sample.window); + if (sample.known && sample.window != None && steam_wrapper(display, sample.window)) { + sample.pid = steam_window_pid(display, sample.window); + sample.steam = sample.pid > 0; + int revert; + XGetInputFocus(display, &sample.focus, &revert); + if (sample.steam) sample.innerFocus = steam_browser_child(display, sample.window, sample.pid, sample.focus); + } + if (cthulhu_x11_end_read(display)) sample = (SteamFocusSample){0}; + return sample; +} + +int cthulhu_restore_steam_focus(Display *display, Window wrapper, int pid, Window target) { + cthulhu_x11_begin_read(display); + int restored = 0, revert; + Window focus, active = None; + XGetInputFocus(display, &focus, &revert); + char heldKeys[32]; + XQueryKeymap(display, heldKeys); + int keyHeld = 0; + for (int i = 0; i < 32; i++) keyHeld |= heldKeys[i]; + if (!keyHeld && focus == wrapper && steam_active_window(display, &active) && active == wrapper && + steam_wrapper(display, wrapper) && + steam_window_pid(display, wrapper) == pid && steam_browser_child(display, wrapper, pid, target)) { + // Recheck foreground immediately before changing input focus: cached + // children must never pull keyboard input out of a different client. + if (steam_active_window(display, &active) && active == wrapper) { + XGetInputFocus(display, &focus, &revert); + if (focus == wrapper) { + XSetInputFocus(display, target, RevertToParent, CurrentTime); + XSync(display, False); + XGetInputFocus(display, &focus, &revert); + restored = focus == target; + } + } + } + if (cthulhu_x11_end_read(display)) restored = 0; + return restored; +} diff --git a/internal/platform/xorg/steam_focus.go b/internal/platform/xorg/steam_focus.go new file mode 100644 index 0000000..25aebb6 --- /dev/null +++ b/internal/platform/xorg/steam_focus.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +/* +#include "commands_native.h" +*/ +import "C" + +type steamFocusSample struct { + window, focus uint64 + pid int + known, steam bool + innerFocus bool +} + +// The command owner retains one verified Steam browser child. A foreground +// transition arms at most one repair during the next ten 100ms focus samples. +// Steam's own restoration wins; we never choose a child from its geometry. +type steamFocusRepair struct { + previousWindow, window, target uint64 + pid int + checksLeft, outerSamples int +} + +func (repair *steamFocusRepair) observe(sample steamFocusSample) uint64 { + if !sample.known { + return 0 + } + if sample.window != repair.previousWindow { + repair.previousWindow = sample.window + repair.checksLeft, repair.outerSamples = 10, 0 + } + if !sample.steam { + repair.checksLeft, repair.outerSamples = 0, 0 + return 0 + } + if sample.innerFocus { + repair.window, repair.pid, repair.target = sample.window, sample.pid, sample.focus + repair.outerSamples = 0 + } + if repair.checksLeft == 0 { + return 0 + } + repair.checksLeft-- + if sample.focus != sample.window { + repair.outerSamples = 0 + return 0 + } + repair.outerSamples++ + if repair.outerSamples < 2 || sample.window != repair.window || sample.pid != repair.pid { + return 0 + } + return repair.target +} + +func read_steam_focus(display *C.Display) steamFocusSample { + value := C.cthulhu_read_steam_focus(display) + return steamFocusSample{window: uint64(value.window), focus: uint64(value.focus), pid: int(value.pid), + known: value.known != 0, steam: value.steam != 0, innerFocus: value.innerFocus != 0} +} + +func restore_steam_focus(display *C.Display, window uint64, pid int, target uint64) bool { + return C.cthulhu_restore_steam_focus(display, C.Window(window), C.int(pid), C.Window(target)) != 0 +} + +func (owner *commandOwner) check_steam_focus() { + if owner.listener.secure.Load() || owner.workerContext.Err() != nil { + owner.steamFocus.checksLeft = 0 + return + } + sample := read_steam_focus(owner.device.display) + target := owner.steamFocus.observe(sample) + if target == 0 || !owner.commandGrabsAllowed() || len(owner.held) != 0 || owner.device.active != 0 || + owner.learnMode || owner.uiSession != 0 || owner.ocrReview || owner.ocrPending { + return + } + if restore_steam_focus(owner.device.display, sample.window, sample.pid, target) { + owner.steamFocus.checksLeft = 0 + } +} diff --git a/internal/platform/xorg/steam_focus_native_test.go b/internal/platform/xorg/steam_focus_native_test.go new file mode 100644 index 0000000..dfa391b --- /dev/null +++ b/internal/platform/xorg/steam_focus_native_test.go @@ -0,0 +1,153 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "runtime" + "testing" + "time" +) + +func TestNativeSteamFocusRepair(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_STEAM_FOCUS") != "1" { + t.Skip("requires private X11 Steam fixture") + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + fixture := exec.CommandContext(ctx, "python3", "-u", "../../../tests/steam_focus_fixture.py") + stdin, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err := fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { stdin.Close(); fixture.Wait() }() + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + t.Fatal("Steam fixture did not start") + } + var windows map[string]uint64 + if err := json.Unmarshal(scanner.Bytes(), &windows); err != nil { + t.Fatal(err) + } + command := func(action, window string) { + t.Helper() + data, _ := json.Marshal(map[string]string{"action": action, "window": window}) + if _, err := stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + if !scanner.Scan() || scanner.Text() != "ready" { + t.Fatal("Steam fixture command failed") + } + } + key := func(action, name string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", action, name).CombinedOutput(); err != nil { + t.Fatalf("key: %v %s", err, out) + } + } + reader := OpenForegroundReader() + if reader == nil { + t.Fatal("cannot open private X11 display") + } + defer reader.Close() + sample := read_steam_focus(reader.display) + if !sample.steam || !sample.innerFocus || sample.focus != windows["browser"] { + t.Fatalf("initial browser focus not recognized: %+v", sample) + } + repair := steamFocusRepair{} + repair.observe(sample) + roundTrip := func() uint64 { + t.Helper() + command("activate", "other") + repair.observe(read_steam_focus(reader.display)) + command("activate", "steam") + if target := repair.observe(read_steam_focus(reader.display)); target != 0 { + t.Fatal("Steam restoration grace sample was skipped") + } + return repair.observe(read_steam_focus(reader.display)) + } + target := roundTrip() + if target != windows["browser"] || !restore_steam_focus(reader.display, sample.window, sample.pid, target) { + t.Fatal("main browser keyboard focus was not repaired") + } + key("key", "Escape") + data, _ := json.Marshal(map[string]string{"action": "events"}) + if _, err := stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + if !scanner.Scan() { + t.Fatal("fixture key delivery missing") + } + var events []struct { + Window uint64 + Pressed bool + Escape bool + } + if err := json.Unmarshal(scanner.Bytes(), &events); err != nil { + t.Fatal(err) + } + if len(events) != 2 || !events[0].Escape || !events[0].Pressed || !events[1].Escape || events[1].Pressed || + events[0].Window != target || events[1].Window != target { + t.Fatalf("Escape was not delivered to restored browser: %+v", events) + } + command("focus", "popup") + if target := repair.observe(read_steam_focus(reader.display)); target != 0 { + t.Fatal("valid popup focus was overridden") + } + target = roundTrip() + if target != windows["popup"] || !restore_steam_focus(reader.display, sample.window, sample.pid, target) { + t.Fatal("popup keyboard focus was not repaired") + } + command("focus", "browser") + if restore_steam_focus(reader.display, sample.window, sample.pid, target) { + t.Fatal("valid search/control browser focus was overridden") + } + command("activate", "steam") + key("keydown", "Control_L") + blocked := restore_steam_focus(reader.display, sample.window, sample.pid, target) + key("keyup", "Control_L") + if blocked { + t.Fatal("focus moved while a physical modifier was held") + } + command("activate", "other") + if restore_steam_focus(reader.display, sample.window, sample.pid, target) { + t.Fatal("repair stole focus from another application") + } + command("activate", "steam") + if restore_steam_focus(reader.display, sample.window, sample.pid, windows["otherChild"]) { + t.Fatal("repair targeted an unrelated child") + } + command("unmap", "popupHost") + if restore_steam_focus(reader.display, sample.window, sample.pid, target) { + t.Fatal("repair focused a hidden popup") + } + command("map", "popupHost") + command("pid", "popup") + if restore_steam_focus(reader.display, sample.window, sample.pid, target) { + t.Fatal("repair focused a child from a different process") + } + command("destroy", "popup") + if restore_steam_focus(reader.display, sample.window, sample.pid, target) { + t.Fatal("repair focused a destroyed popup") + } + command("class", "steam") + if read_steam_focus(reader.display).steam || restore_steam_focus(reader.display, sample.window, sample.pid, windows["browser"]) { + t.Fatal("repair changed focus in an ordinary application") + } +} diff --git a/internal/platform/xorg/steam_focus_test.go b/internal/platform/xorg/steam_focus_test.go new file mode 100644 index 0000000..d2079a7 --- /dev/null +++ b/internal/platform/xorg/steam_focus_test.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +import "testing" + +func TestSteamFocusRepairActivation(t *testing.T) { + repair := steamFocusRepair{} + inner := steamFocusSample{known: true, steam: true, window: 10, focus: 12, pid: 123, innerFocus: true} + if target := repair.observe(inner); target != 0 { + t.Fatal("valid browser focus requested a repair") + } + repair.observe(steamFocusSample{known: true, window: 20, focus: 20}) + outer := inner + outer.focus, outer.innerFocus = 10, false + if target := repair.observe(outer); target != 0 { + t.Fatal("repair did not allow Steam its first restoration sample") + } + if target := repair.observe(outer); target != 12 { + t.Fatalf("repair target = %d, want remembered browser child 12", target) + } +} + +func TestSteamFocusRepairPreservesControlAndWindowBoundaries(t *testing.T) { + for _, test := range []struct { + name string + sample steamFocusSample + }{ + {"other application", steamFocusSample{known: true, window: 20, focus: 20}}, + {"other Steam window", steamFocusSample{known: true, steam: true, window: 30, focus: 30, pid: 123}}, + {"reused window with different process", steamFocusSample{known: true, steam: true, window: 10, focus: 10, pid: 456}}, + {"already focused browser popup", steamFocusSample{known: true, steam: true, window: 10, focus: 14, pid: 123, innerFocus: true}}, + {"external keyboard focus", steamFocusSample{known: true, steam: true, window: 10, focus: 99, pid: 123}}, + {"unknown foreground", steamFocusSample{}}, + } { + t.Run(test.name, func(t *testing.T) { + repair := steamFocusRepair{} + repair.observe(steamFocusSample{known: true, steam: true, window: 10, focus: 12, pid: 123, innerFocus: true}) + repair.observe(steamFocusSample{known: true, window: 20}) + for i := 0; i < 12; i++ { + if target := repair.observe(test.sample); target != 0 { + t.Fatalf("unexpected focus repair to %d", target) + } + } + }) + } +} + +func TestSteamFocusRepairRemembersPopupAndExpires(t *testing.T) { + repair := steamFocusRepair{} + inner := steamFocusSample{known: true, steam: true, window: 10, focus: 12, pid: 123, innerFocus: true} + repair.observe(inner) + inner.focus = 14 + repair.observe(inner) + repair.observe(steamFocusSample{known: true, window: 20}) + outer := inner + outer.focus, outer.innerFocus = 10, false + repair.observe(outer) + if target := repair.observe(outer); target != 14 { + t.Fatalf("target = %d, want most recently focused popup 14", target) + } + for i := 0; i < 10; i++ { + repair.observe(inner) + } + if target := repair.observe(outer); target != 0 { + t.Fatalf("repair remained armed after activation window expired: %d", target) + } +} diff --git a/tests/steam_focus_fixture.py b/tests/steam_focus_fixture.py new file mode 100644 index 0000000..f5d6bd9 --- /dev/null +++ b/tests/steam_focus_fixture.py @@ -0,0 +1,75 @@ +"""Steam-shaped X11 browser and popup fixture for a private X server.""" + +import json +import os +import sys + +from Xlib import X, Xatom, XK, display +from Xlib.xobject.drawable import Window + + +def main() -> None: + connection = display.Display() + root = connection.screen().root + activeAtom = connection.intern_atom("_NET_ACTIVE_WINDOW") + pidAtom = connection.intern_atom("_NET_WM_PID") + + def make_window(parent: Window, width: int, height: int) -> Window: + window = parent.create_window(0, 0, width, height, 0, + connection.screen().root_depth, + event_mask=X.KeyPressMask | X.KeyReleaseMask) + window.change_property(pidAtom, Xatom.CARDINAL, 32, [os.getpid()]) + window.map() + return window + + steam = make_window(root, 800, 600) + steam.set_wm_class("steamwebhelper", "steam") + mainHost = make_window(steam, 800, 600) + browser = make_window(mainHost, 800, 600) + popupHost = make_window(steam, 300, 600) + popup = make_window(popupHost, 300, 600) + other = make_window(root, 800, 600) + other.set_wm_class("example", "Example") + otherChild = make_window(other, 800, 600) + windows = dict(steam=steam, browser=browser, popup=popup, + popupHost=popupHost, other=other, otherChild=otherChild) + root.change_property(activeAtom, Xatom.WINDOW, 32, [steam.id]) + browser.set_input_focus(X.RevertToParent, X.CurrentTime) + connection.sync() + print(json.dumps({name: window.id for name, window in windows.items()}), flush=True) + for line in sys.stdin: + command = json.loads(line) + action = command["action"] + window = windows[command.get("window", "steam")] + if action == "activate": + root.change_property(activeAtom, Xatom.WINDOW, 32, [window.id]) + window.set_input_focus(X.RevertToParent, X.CurrentTime) + elif action == "focus": + window.set_input_focus(X.RevertToParent, X.CurrentTime) + elif action == "unmap": + window.unmap() + elif action == "map": + window.map() + elif action == "destroy": + window.destroy() + elif action == "class": + window.set_wm_class("example", "Example") + elif action == "pid": + window.change_property(pidAtom, Xatom.CARDINAL, 32, [os.getpid() + 1]) + elif action == "events": + connection.sync() + events = [] + while connection.pending_events(): + event = connection.next_event() + if event.type in (X.KeyPress, X.KeyRelease): + events.append({"window": event.window.id, "pressed": event.type == X.KeyPress, + "escape": event.detail == connection.keysym_to_keycode(XK.string_to_keysym("Escape"))}) + print(json.dumps(events), flush=True) + continue + connection.sync() + print("ready", flush=True) + connection.close() + + +if __name__ == "__main__": + main() diff --git a/tests/window_titles.md b/tests/window_titles.md index 2087fed..2a38ab7 100644 --- a/tests/window_titles.md +++ b/tests/window_titles.md @@ -6,10 +6,18 @@ that window once, retain its pending announcement across Wine control focus updates, and introduce it again after a foreground switch. Title changes within the window still follow the application's title-change preference. +Fresh input cancels titles still waiting for their coalescing deadline or an +application override, as well as titles already in the speech queue. Held-key +repeats retain automatic titles; Control and Escape remain explicit stop keys. +Input that initiates a window switch precedes the destination's title and must +not cancel that new introduction. Later title changes remain enabled normally. + Run the focused checks from the repository root: ```sh go test -race ./internal/app -run 'Title|WineReview|XTerm|Foreground' -count=1 +CTHULHU_NATIVE_BROWSER_TESTS=editing GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off python3 tests/native_browser.py +xvfb-run -a dbus-run-session -- env CTHULHU_NATIVE_WINDOW_TITLES=1 GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test ./internal/app -run '^TestNativeAutomaticTitleDispatchLatency$' -count=1 -v dbus-run-session -- env CTHULHU_WINE_TITLE_DBUS=1 go test -race ./internal/app -run '^TestPrivateWineTitleSurvivesReviewFocusBurst$' -count=1 -v ``` @@ -33,6 +41,14 @@ replacement with its user. Review and listening are required before submission. stay quiet. With the preference enabled, changed titles should speak once. 5. If used, repeat with a Wine virtual desktop and check title messages on a physical Braille display. +6. In Brave, use Ctrl+L and immediately type several characters. Each character + should echo and interrupt previous speech without an older title following + it. Repeat while a long title is speaking, and use Control and Escape to stop + it. Switch away and back without typing; the window title must still speak. +7. With automatic title reading enabled, change a page's title and verify it + speaks once. The editing fixture's F8 key changes its title to + "Cthulhu Browser Probe updated". Check address suggestions, deletion, and + native caret movement still announce their usual output. Automated queue assertions do not establish physical Alt+Tab, audible speech, installed-binary behavior, or physical Braille acceptance.