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:
1 parent
9205e7280a
commit
12c514aab0
19 files changed
+798
-30
No files matched your search
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user