diff --git a/internal/app/linked_headings_native_test.go b/internal/app/linked_headings_native_test.go index 2d7f115..81aba44 100644 --- a/internal/app/linked_headings_native_test.go +++ b/internal/app/linked_headings_native_test.go @@ -176,14 +176,19 @@ func testLinkedHeadingSoundGeometry(t *testing.T, ctx context.Context, adapter * } defer worker.Stop(context.Background()) for _, step := range []struct { + target review.DocumentTarget + level int backwards bool name string }{ - {false, "Offscreen first topic"}, - {false, "Offscreen second topic"}, - {true, "Offscreen first topic"}, + {review.DocumentHeading, 6, false, "Offscreen first topic"}, + {review.DocumentHeading, 6, false, "Offscreen second topic"}, + {review.DocumentHeading, 6, true, "Offscreen first topic"}, + {review.DocumentLink, 0, false, "Offscreen first topic"}, + {review.DocumentLink, 0, false, "Offscreen second topic"}, + {review.DocumentLink, 0, true, "Offscreen first topic"}, } { - if err := worker.MoveDocument(review.DocumentHeading, 6, step.backwards); err != nil { + if err := worker.MoveDocument(step.target, step.level, step.backwards); err != nil { t.Fatal(err) } select { @@ -193,6 +198,9 @@ func testLinkedHeadingSoundGeometry(t *testing.T, ctx context.Context, adapter * t.Fatalf("scrolled heading result = %+v", result) } link := result.DocumentPosition.LinkedHeading + if step.target == review.DocumentLink { + link = &result.DocumentPosition.Snapshot + } if link == nil || !link.SoundGeometry.ObjectKnown || !link.SoundGeometry.ReferenceKnown { t.Fatalf("scrolled link lacks fresh geometry: %+v", link) } diff --git a/internal/atspi/adapter_document_native.go b/internal/atspi/adapter_document_native.go index cd01d44..4823148 100644 --- a/internal/atspi/adapter_document_native.go +++ b/internal/atspi/adapter_document_native.go @@ -93,8 +93,10 @@ func (adapter *Adapter) CaptureDocumentSoundGeometry(ctx context.Context, object if !adapter.captureSound.Load() { return [2]SoundGeometry{}, nil } - result, err := adapter.documentOperation(ctx, treeCaptureRequest{soundObjects: [2]ObjectReference{object, link}}) - return result.soundGeometry, err + return retryDocumentSoundGeometry(ctx, link.Valid(), func(ctx context.Context) ([2]SoundGeometry, error) { + result, err := adapter.documentOperation(ctx, treeCaptureRequest{soundObjects: [2]ObjectReference{object, link}}) + return result.soundGeometry, err + }) } func (adapter *Adapter) captureNativeDocumentSoundGeometry(ctx context.Context, objects [2]ObjectReference) ([2]SoundGeometry, error) { diff --git a/internal/atspi/document_sound.go b/internal/atspi/document_sound.go new file mode 100644 index 0000000..0eec8d2 --- /dev/null +++ b/internal/atspi/document_sound.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "context" + "time" +) + +// A successful ScrollTo can precede the browser's visibility/geometry update. +// Retry missing geometry on the calling worker, leaving the native event owner +// free to process those updates. Unsupported geometry gets at most 50ms of +// extra waiting; native calls remain bounded by the caller's context. +func retryDocumentSoundGeometry(ctx context.Context, linked bool, capture func(context.Context) ([2]SoundGeometry, error)) ([2]SoundGeometry, error) { + for attempt := 0; ; attempt++ { + if err := ctx.Err(); err != nil { + return [2]SoundGeometry{}, err + } + geometry, err := capture(ctx) + if err != nil { + return [2]SoundGeometry{}, err + } + ready := geometry[0].ObjectKnown && geometry[0].ReferenceKnown + if linked { + ready = ready && geometry[1].ObjectKnown && geometry[1].ReferenceKnown + } + if ready || attempt == 5 { + return geometry, nil + } + timer := time.NewTimer(10 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return [2]SoundGeometry{}, ctx.Err() + case <-timer.C: + } + } +} diff --git a/internal/atspi/document_sound_test.go b/internal/atspi/document_sound_test.go new file mode 100644 index 0000000..99fe28c --- /dev/null +++ b/internal/atspi/document_sound_test.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "context" + "errors" + "testing" +) + +func TestDocumentSoundGeometryWaitsForBrowserScroll(t *testing.T) { + ready := SoundGeometry{ObjectKnown: true, ReferenceKnown: true, + Object: Rectangle{X: 18, Y: 147, Width: 102, Height: 13}, + Reference: Rectangle{Width: 800, Height: 600}} + for _, linked := range []bool{false, true} { + calls := 0 + geometry, err := retryDocumentSoundGeometry(context.Background(), linked, func(context.Context) ([2]SoundGeometry, error) { + calls++ + // Scroll completion can reach the heading and link at different times. + if calls == 1 { + return [2]SoundGeometry{}, nil + } + if calls == 2 { + return [2]SoundGeometry{ready, {}}, nil + } + return [2]SoundGeometry{ready, ready}, nil + }) + wantCalls := 2 + if linked { + wantCalls = 3 + } + if err != nil || calls != wantCalls || geometry[0] != ready || linked && geometry[1] != ready { + t.Fatalf("linked=%t: calls=%d geometry=%+v err=%v", linked, calls, geometry, err) + } + } +} + +func TestDocumentSoundGeometryRetryBoundsAndCancellation(t *testing.T) { + for _, scenario := range []string{"ready", "unavailable", "stale", "cancelled", "already cancelled"} { + t.Run(scenario, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if scenario == "already cancelled" { + cancel() + } + calls := 0 + _, err := retryDocumentSoundGeometry(ctx, false, func(context.Context) ([2]SoundGeometry, error) { + calls++ + switch scenario { + case "ready": + return [2]SoundGeometry{{ObjectKnown: true, ReferenceKnown: true}, {}}, nil + case "stale": + return [2]SoundGeometry{}, ErrStaleObject + case "cancelled": + cancel() + } + return [2]SoundGeometry{}, nil + }) + wantCalls, wantErr := 1, error(nil) + switch scenario { + case "unavailable": + wantCalls = 6 + case "stale": + wantErr = ErrStaleObject + case "cancelled": + wantErr = context.Canceled + case "already cancelled": + wantCalls, wantErr = 0, context.Canceled + } + if calls != wantCalls || !errors.Is(err, wantErr) { + t.Fatalf("calls=%d err=%v, want calls=%d err=%v", calls, err, wantCalls, wantErr) + } + }) + } +} diff --git a/internal/platform/xorg/commands_native.c b/internal/platform/xorg/commands_native.c index 38810d3..9652190 100644 --- a/internal/platform/xorg/commands_native.c +++ b/internal/platform/xorg/commands_native.c @@ -171,7 +171,7 @@ Foreground read_foreground(Display *display) { pthread_mutex_unlock(&error_mutex); return result; } -static int cthulhu_display_active_window_xterm_match_unchecked(Display *display) { +int cthulhu_focus_metadata_unchecked(Display *display, SteamFocusSample *steam) { Window root = DefaultRootWindow(display); Atom activeAtom = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); Atom type = None; @@ -184,6 +184,10 @@ static int cthulhu_display_active_window_xterm_match_unchecked(Display *display) } Window window = ((Window *)data)[0]; XFree(data); + if (steam != NULL) { + steam->known = 1; + steam->window = window; + } if (window == None || window == root) { return 0; } @@ -197,6 +201,10 @@ static int cthulhu_display_active_window_xterm_match_unchecked(Display *display) char *identifier = (char *)data; if (cthulhu_identifier_is_xterm(identifier)) matched = 1; char *classIdentifier = identifier + strlen(identifier) + 1; + if (steam != NULL && (unsigned char *)classIdentifier < data + items) { + steam->steam = strcmp(identifier, "steamwebhelper") == 0 && + strcmp(classIdentifier, "steam") == 0; + } if (!matched && (unsigned char *)classIdentifier < data + items) { matched = cthulhu_identifier_is_xterm(classIdentifier); } @@ -236,7 +244,7 @@ int cthulhu_display_active_window_xterm_match(Display *display) { XSync(display,False); error_display=display; grab_error=0; previous_error_handler=XSetErrorHandler(command_error); - int match=cthulhu_display_active_window_xterm_match_unchecked(display); + int match=cthulhu_focus_metadata_unchecked(display, NULL); XSync(display,False); if (grab_error) match=0; XSetErrorHandler(previous_error_handler); error_display=NULL; diff --git a/internal/platform/xorg/commands_native.h b/internal/platform/xorg/commands_native.h index 01eba64..3d56af9 100644 --- a/internal/platform/xorg/commands_native.h +++ b/internal/platform/xorg/commands_native.h @@ -31,12 +31,16 @@ typedef struct { } Foreground; typedef struct { int known, pid, x, y; unsigned long window; } PointerPosition; typedef struct { int known, pid, steam, innerFocus; Window window, focus; } SteamFocusSample; +typedef struct { int xterm; SteamFocusSample steam; } CommandFocusSample; 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); +// Caller holds the X11 read guard; optional Steam metadata shares the same +// active-window and WM_CLASS reads as the XTerm match. +int cthulhu_focus_metadata_unchecked(Display *display, SteamFocusSample *steam); +CommandFocusSample cthulhu_read_command_focus(Display *display, int steam_enabled); 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); diff --git a/internal/platform/xorg/commands_owner_loop.go b/internal/platform/xorg/commands_owner_loop.go index f9566ab..d213707 100644 --- a/internal/platform/xorg/commands_owner_loop.go +++ b/internal/platform/xorg/commands_owner_loop.go @@ -72,8 +72,7 @@ func (owner *commandOwner) run() { return } if time.Since(owner.lastFocusCheck) >= xtermFocusCheckInterval { - owner.publishFocus() - owner.check_steam_focus() + owner.check_steam_focus(owner.publishFocus()) } owner.resetForSecureInput() if !owner.syncGrabs() { @@ -104,16 +103,17 @@ func (owner *commandOwner) run() { } } -func (owner *commandOwner) publishFocus() { - match := C.cthulhu_display_active_window_xterm_match(owner.device.display) +func (owner *commandOwner) publishFocus() steamFocusSample { + match, steam := read_command_focus(owner.device.display, !owner.listener.secure.Load() && owner.workerContext.Err() == nil) owner.lastFocusCheck = time.Now() - if match == 0 || match == owner.lastFocusMatch { - return + if match == 0 || C.int(match) == owner.lastFocusMatch { + return steam } - owner.lastFocusMatch = match + owner.lastFocusMatch = C.int(match) state := FocusState{Known: true, XTerm: match == 1} owner.listener.SetCommandGrabs(!state.XTerm) owner.listener.publishFocusState(state) + return steam } // Native xterm focus vetoes delayed accessibility requests to restore grabs. diff --git a/internal/platform/xorg/steam_focus.c b/internal/platform/xorg/steam_focus.c index f76a65e..4afbb29 100644 --- a/internal/platform/xorg/steam_focus.c +++ b/internal/platform/xorg/steam_focus.c @@ -61,19 +61,20 @@ static int steam_browser_child(Display *display, Window wrapper, int pid, Window return 0; } -SteamFocusSample cthulhu_read_steam_focus(Display *display) { - SteamFocusSample sample = {0}; +CommandFocusSample cthulhu_read_command_focus(Display *display, int steam_enabled) { + CommandFocusSample result = {0}; + SteamFocusSample *sample = &result.steam; 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; + result.xterm = cthulhu_focus_metadata_unchecked(display, steam_enabled ? sample : NULL); + if (sample->steam) { + 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); + 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; + if (cthulhu_x11_end_read(display)) result = (CommandFocusSample){0}; + return result; } int cthulhu_restore_steam_focus(Display *display, Window wrapper, int pid, Window target) { diff --git a/internal/platform/xorg/steam_focus.go b/internal/platform/xorg/steam_focus.go index 25aebb6..a9c7c82 100644 --- a/internal/platform/xorg/steam_focus.go +++ b/internal/platform/xorg/steam_focus.go @@ -56,9 +56,14 @@ func (repair *steamFocusRepair) observe(sample steamFocusSample) uint64 { 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), +func read_command_focus(display *C.Display, steamEnabled bool) (int, steamFocusSample) { + var enabled C.int + if steamEnabled { + enabled = 1 + } + result := C.cthulhu_read_command_focus(display, enabled) + value := result.steam + return int(result.xterm), 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} } @@ -66,12 +71,11 @@ func restore_steam_focus(display *C.Display, window uint64, pid int, target uint return C.cthulhu_restore_steam_focus(display, C.Window(window), C.int(pid), C.Window(target)) != 0 } -func (owner *commandOwner) check_steam_focus() { +func (owner *commandOwner) check_steam_focus(sample steamFocusSample) { 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 { diff --git a/internal/platform/xorg/steam_focus_native_test.go b/internal/platform/xorg/steam_focus_native_test.go index dfa391b..fd53865 100644 --- a/internal/platform/xorg/steam_focus_native_test.go +++ b/internal/platform/xorg/steam_focus_native_test.go @@ -66,7 +66,19 @@ func TestNativeSteamFocusRepair(t *testing.T) { t.Fatal("cannot open private X11 display") } defer reader.Close() - sample := read_steam_focus(reader.display) + readSteam := func() steamFocusSample { + t.Helper() + match, sample := read_command_focus(reader.display, true) + if match != 2 { + t.Fatalf("Steam/ordinary window XTerm match = %d", match) + } + return sample + } + match, disabled := read_command_focus(reader.display, false) + if match != 2 || disabled != (steamFocusSample{}) { + t.Fatalf("disabled Steam sampling = %d, %+v", match, disabled) + } + sample := readSteam() if !sample.steam || !sample.innerFocus || sample.focus != windows["browser"] { t.Fatalf("initial browser focus not recognized: %+v", sample) } @@ -75,12 +87,16 @@ func TestNativeSteamFocusRepair(t *testing.T) { roundTrip := func() uint64 { t.Helper() command("activate", "other") - repair.observe(read_steam_focus(reader.display)) + other := readSteam() + if !other.known || other.window != windows["other"] || other.steam || other.focus != 0 || other.pid != 0 { + t.Fatalf("ordinary window queried Steam details: %+v", other) + } + repair.observe(other) command("activate", "steam") - if target := repair.observe(read_steam_focus(reader.display)); target != 0 { + if target := repair.observe(readSteam()); target != 0 { t.Fatal("Steam restoration grace sample was skipped") } - return repair.observe(read_steam_focus(reader.display)) + return repair.observe(readSteam()) } target := roundTrip() if target != windows["browser"] || !restore_steam_focus(reader.display, sample.window, sample.pid, target) { @@ -107,7 +123,7 @@ func TestNativeSteamFocusRepair(t *testing.T) { 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 { + if target := repair.observe(readSteam()); target != 0 { t.Fatal("valid popup focus was overridden") } target = roundTrip() @@ -147,7 +163,17 @@ func TestNativeSteamFocusRepair(t *testing.T) { 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"]) { + if readSteam().steam || restore_steam_focus(reader.display, sample.window, sample.pid, windows["browser"]) { t.Fatal("repair changed focus in an ordinary application") } + command("xterm", "steam") + match, terminal := read_command_focus(reader.display, true) + if match != 1 || !terminal.known || terminal.steam || terminal.focus != 0 { + t.Fatalf("XTerm class change = %d, %+v", match, terminal) + } + command("unknown", "steam") + match, unknown := read_command_focus(reader.display, true) + if match != 0 || unknown != (steamFocusSample{}) { + t.Fatalf("unknown foreground = %d, %+v", match, unknown) + } } diff --git a/tests/steam_focus_fixture.py b/tests/steam_focus_fixture.py index f5d6bd9..1ea3bad 100644 --- a/tests/steam_focus_fixture.py +++ b/tests/steam_focus_fixture.py @@ -54,6 +54,10 @@ def main() -> None: window.destroy() elif action == "class": window.set_wm_class("example", "Example") + elif action == "xterm": + window.set_wm_class("xterm", "XTerm") + elif action == "unknown": + root.delete_property(activeAtom) elif action == "pid": window.change_property(pidAtom, Xatom.CARDINAL, 32, [os.getpid() + 1]) elif action == "events":