Sometimes when switching from steam to a different workspace and back only the outer window takes focus so some actions don't work. Tentative fix for that.

This commit is contained in:
Storm Dragon committed 2026-10-14 19:51:07 -04:00
1 parent 9205e7280a
commit 12c514aab0
19 files changed
+798 -30

No files matched your search

+1
View File
@@ -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).
+67
View File
@@ -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.
+21 -4
View File
@@ -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
+1 -1
View File
@@ -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 {
+1 -2
View File
@@ -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)
}
+17 -7
View File
@@ -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())
+41 -3
View File
@@ -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):
}
}
+85
View File
@@ -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{}
+6
View File
@@ -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")
}
+21 -13
View File
@@ -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}
}
+30
View File
@@ -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)
}
}
+3
View File
@@ -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);
@@ -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() {
+105
View File
@@ -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 <X11/Xatom.h>
#include <X11/Xutil.h>
#include <string.h>
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;
}
+83
View File
@@ -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
}
}
@@ -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")
}
}
@@ -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)
}
}
+75
View File
@@ -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()
+16
View File
@@ -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.