From d5f8a392cffa76dc7826b48da07fefb105e7d6b2 Mon Sep 17 00:00:00 2001 From: Angus Kola Date: Sat, 7 Nov 2026 19:49:00 -0500 Subject: [PATCH] Resume Cthulhu on empty i3 workspaces --- docs/testing/xterm-handoff.md | 20 +++- internal/i3ipc/i3_native_test.go | 102 ++++++++++++++++++ internal/platform/xorg/commands_native.c | 2 +- internal/platform/xorg/commands_native.h | 3 + internal/platform/xorg/commands_owner_loop.go | 2 +- internal/platform/xorg/i3.go | 41 +------ internal/platform/xorg/i3_native.c | 63 +++++++++++ tests/native_i3.py | 2 +- 8 files changed, 191 insertions(+), 44 deletions(-) create mode 100644 internal/platform/xorg/i3_native.c diff --git a/docs/testing/xterm-handoff.md b/docs/testing/xterm-handoff.md index e477be6..7bac6c1 100644 --- a/docs/testing/xterm-handoff.md +++ b/docs/testing/xterm-handoff.md @@ -10,10 +10,20 @@ has been admitted. It releases its command grabs. On return, Caps Lock tracking adopts the current XKB lock state before treating Caps Lock as the reader modifier again. +On i3, focusing an empty workspace resumes Cthulhu and its shortcuts even +though no accessible application gains focus. This requires i3's verified +window-manager identity, no EWMH active client, and keyboard focus on i3's +support window. Returning to a workspace with focused XTerm suspends Cthulhu +again. Moving or closing the last window also restores shortcuts when i3 +enters that no-client state. This behavior is independent of workspace +announcement preferences. Unknown focus on other desktops keeps the existing +handoff policy. + Focused checks: ```sh go test ./internal/app -run 'Test(XTerm|DelegatedInput)' -count=1 +python3 tests/native_i3.py python3 tests/native_x11.py ``` @@ -36,4 +46,12 @@ reader and physical keyboard handling require live verification. Control, or Shift when using the “all keys” interruption setting. Expect Cthulhu's current announcement and queued continuation to stop promptly. Fenrir must continue receiving terminal keys, with no Cthulhu key echo. -6. Record the exact keys, focused application, speech and Braille for failures. +6. With XTerm in workspace 7, switch to empty workspace 8. Expect the normal + workspace announcement if enabled. Press Cthulhu+Space; expect Preferences + to open and its controls to speak and appear on Braille. Close Preferences, + return to workspace 7, and check that Fenrir receives its modifier and + navigation keys again, without Cthulhu key echo. Repeat with a floating + XTerm, and with i3 workspace announcements disabled. The shortcuts must + resume on the empty workspace in each case. Also visit an ordinary editor + or file manager and confirm normal Cthulhu focus speech and Braille. +7. Record the exact keys, focused application, speech and Braille for failures. diff --git a/internal/i3ipc/i3_native_test.go b/internal/i3ipc/i3_native_test.go index 439b996..ada6b5f 100644 --- a/internal/i3ipc/i3_native_test.go +++ b/internal/i3ipc/i3_native_test.go @@ -15,9 +15,93 @@ import ( "testing" "time" + "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) +func TestNativeI3EmptyWorkspaceFocus(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_I3") != "1" { + t.Skip("requires private i3 harness") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + command := func(text string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "i3-msg", "-s", xorg.I3Socket(), text).CombinedOutput(); err != nil { + t.Fatalf("i3 command: %v %s", err, out) + } + } + command(`workspace "handoff terminal"`) + terminal := exec.CommandContext(ctx, "xterm", "-T", "handoff fixture", "-e", "sleep", "30") + if err := terminal.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = terminal.Process.Kill(); _ = terminal.Wait() }() + listener := xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + waitFor := func(check func() bool, message string) { + t.Helper() + deadline := time.Now().Add(time.Second) + for !check() { + if time.Now().After(deadline) { + t.Fatal(message) + } + time.Sleep(time.Millisecond) + } + } + wantFocus := func(xterm bool) { + t.Helper() + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + for { + select { + case state := <-listener.FocusChanges(): + if state.Known && state.XTerm == xterm { + return + } + case err := <-listener.Failures(): + t.Fatal(err) + case <-timer.C: + out, _ := exec.CommandContext(ctx, "xprop", "-root", "_NET_ACTIVE_WINDOW", "_NET_SUPPORTING_WM_CHECK").CombinedOutput() + focus, _ := exec.CommandContext(ctx, "xdotool", "getwindowfocus").CombinedOutput() + t.Fatalf("missing focus state XTerm=%t; %s input focus: %s", xterm, out, focus) + } + } + } + wantFocus(true) + for _, floating := range []string{"disable", "enable"} { + command(`[title="handoff fixture"] floating ` + floating) + command(`workspace "handoff empty"`) + wantFocus(false) + waitFor(listener.CommandGrabsEnabled, "empty workspace did not restore grabs") + // A real shortcut proves that the native XTerm veto was cleared, + // rather than merely reporting the new state to the app. + if out, err := exec.CommandContext(ctx, "xdotool", "key", "Insert+h").CombinedOutput(); err != nil { + t.Fatalf("shortcut: %v %s", err, out) + } + select { + case event := <-listener.Commands(): + if event.Command != input.ToggleLearnMode { + t.Fatalf("empty-workspace shortcut = %+v", event) + } + case <-ctx.Done(): + t.Fatal("empty-workspace shortcut did not dispatch") + } + // Returning to XTerm must end learn mode and release its keyboard. + command(`workspace "handoff terminal"`) + wantFocus(true) + waitFor(func() bool { return !listener.CommandGrabsEnabled() && !listener.LearnModeActive() }, + "returning to XTerm kept command or learn-mode grabs") + } + // Losing the last client without switching workspaces uses the same path. + command(`[title="handoff fixture"] move container to workspace "handoff moved"`) + wantFocus(false) + waitFor(listener.CommandGrabsEnabled, "moving the last client did not restore grabs") +} + func TestNativeI3Events(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_I3") != "1" { t.Skip("requires private i3 harness") @@ -220,4 +304,22 @@ func TestNativeI3Unavailable(t *testing.T) { if path := xorg.I3Socket(); path != "" { t.Fatalf("activated without i3: %q", path) } + // No active client on an unverified WM must remain unknown. The i3 + // exception must not relax the existing focus policy on other desktops. + if out, err := exec.Command("xprop", "-root", "-f", "_NET_ACTIVE_WINDOW", "32x", "-set", "_NET_ACTIVE_WINDOW", "0x0").CombinedOutput(); err != nil { + t.Fatalf("set empty foreground: %v %s", err, out) + } + defer exec.Command("xprop", "-root", "-remove", "_NET_ACTIVE_WINDOW").Run() + listener := xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + if err := listener.Start(context.Background()); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + select { + case state := <-listener.FocusChanges(): + t.Fatalf("unverified empty foreground became known: %+v", state) + case err := <-listener.Failures(): + t.Fatal(err) + case <-time.After(200 * time.Millisecond): + } } diff --git a/internal/platform/xorg/commands_native.c b/internal/platform/xorg/commands_native.c index 82e9c7c..96feeb2 100644 --- a/internal/platform/xorg/commands_native.c +++ b/internal/platform/xorg/commands_native.c @@ -312,7 +312,7 @@ int cthulhu_focus_metadata_unchecked(Display *display, SteamFocusSample *steam) steam->window = window; } if (window == None || window == root) { - return 0; + return cthulhu_i3_no_client_focus(display) ? 2 : 0; } Atom classAtom = XInternAtom(display, "WM_CLASS", False); diff --git a/internal/platform/xorg/commands_native.h b/internal/platform/xorg/commands_native.h index b48681f..66f80e4 100644 --- a/internal/platform/xorg/commands_native.h +++ b/internal/platform/xorg/commands_native.h @@ -37,6 +37,9 @@ typedef struct { int known, pid, steam, innerFocus; Window window, focus; } Stea typedef struct { int xterm; SteamFocusSample steam; } CommandFocusSample; int cthulhu_active_window_is_xterm(void); +char *cthulhu_i3_socket(void); +// Caller holds the X11 read guard and has confirmed no EWMH active client. +int cthulhu_i3_no_client_focus(Display *display); int cthulhu_display_active_window_xterm_match(Display *display); Foreground read_foreground(Display *display); int cthulhu_xres_client_pids_supported(Display *display); diff --git a/internal/platform/xorg/commands_owner_loop.go b/internal/platform/xorg/commands_owner_loop.go index a7bbe54..6fd78ee 100644 --- a/internal/platform/xorg/commands_owner_loop.go +++ b/internal/platform/xorg/commands_owner_loop.go @@ -136,7 +136,7 @@ func (owner *commandOwner) publishFocus() steamFocusSample { // Native xterm focus vetoes delayed accessibility requests to restore grabs. // lastFocusMatch belongs to this owner and survives unknown focus samples; -// only a known non-xterm window ends the handoff. +// only a known non-xterm window or verified i3 no-client focus ends the handoff. func (owner *commandOwner) commandGrabsAllowed() bool { return owner.lastFocusMatch != 1 && owner.listener.commandsEnabled.Load() && !owner.listener.secure.Load() } diff --git a/internal/platform/xorg/i3.go b/internal/platform/xorg/i3.go index 4221516..4222fc9 100644 --- a/internal/platform/xorg/i3.go +++ b/internal/platform/xorg/i3.go @@ -6,46 +6,7 @@ package xorg /* #include "commands_native.h" -#include -#include #include -#include - -static unsigned char *i3_property(Display *d, Window w, const char *name, Atom wanted, int bits) { - Atom type; int format; unsigned long count, remaining; - unsigned char *value = NULL; - if (XGetWindowProperty(d, w, XInternAtom(d, name, False), 0, 1024, False, - wanted, &type, &format, &count, &remaining, &value) != Success || - type != wanted || format != bits || remaining || !count) { - if (value) XFree(value); - return NULL; - } - return value; -} - -static char *i3_socket(void) { - Display *d = XOpenDisplay(NULL); - if (!d) return NULL; - cthulhu_x11_begin_read(d); - char *socket = NULL; - Window root = DefaultRootWindow(d); - unsigned char *check = i3_property(d, root, "_NET_SUPPORTING_WM_CHECK", XA_WINDOW, 32); - if (check) { - Window wm = *(unsigned long *)check; - XFree(check); - unsigned char *self = i3_property(d, wm, "_NET_SUPPORTING_WM_CHECK", XA_WINDOW, 32); - unsigned char *name = i3_property(d, wm, "_NET_WM_NAME", XInternAtom(d, "UTF8_STRING", False), 8); - if (self && *(unsigned long *)self == wm && name && !strcmp((char *)name, "i3")) { - unsigned char *path = i3_property(d, root, "I3_SOCKET_PATH", XInternAtom(d, "UTF8_STRING", False), 8); - if (path) { socket = strdup((char *)path); XFree(path); } - } - if (self) XFree(self); - if (name) XFree(name); - } - if (cthulhu_x11_end_read(d)) { free(socket); socket = NULL; } - XCloseDisplay(d); - return socket; -} */ import "C" @@ -61,7 +22,7 @@ func I3Socket() string { if os.Getenv("DISPLAY") == "" { return "" } - path := C.i3_socket() + path := C.cthulhu_i3_socket() if path == nil { return "" } diff --git a/internal/platform/xorg/i3_native.c b/internal/platform/xorg/i3_native.c new file mode 100644 index 0000000..4e43beb --- /dev/null +++ b/internal/platform/xorg/i3_native.c @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "commands_native.h" +#include +#include +#include + +static unsigned char *i3_property(Display *d, Window w, const char *name, Atom wanted, int bits) { + Atom type; int format; unsigned long count, remaining; + unsigned char *value = NULL; + if (XGetWindowProperty(d, w, XInternAtom(d, name, False), 0, 1024, False, + wanted, &type, &format, &count, &remaining, &value) != Success || + type != wanted || format != bits || remaining || !count) { + if (value) XFree(value); + return NULL; + } + return value; +} + +// Caller holds the X11 read guard. Never trust an installed executable or an +// inherited socket variable as evidence that i3 owns this display. +static Window i3_support_window(Display *d) { + Window wm = None; + unsigned char *check = i3_property(d, DefaultRootWindow(d), "_NET_SUPPORTING_WM_CHECK", XA_WINDOW, 32); + if (check) { + Window candidate = *(unsigned long *)check; + XFree(check); + unsigned char *self = i3_property(d, candidate, "_NET_SUPPORTING_WM_CHECK", XA_WINDOW, 32); + unsigned char *name = i3_property(d, candidate, "_NET_WM_NAME", XInternAtom(d, "UTF8_STRING", False), 8); + if (self && *(unsigned long *)self == candidate && name && !strcmp((char *)name, "i3")) + wm = candidate; + if (self) XFree(self); + if (name) XFree(name); + } + return wm; +} + +// Called only when EWMH explicitly reports no active client. i3 focuses its +// support window on an empty workspace. Missing metadata or some other input +// focus must preserve the previous handoff, rather than restoring grabs. +int cthulhu_i3_no_client_focus(Display *d) { + Window wm = i3_support_window(d), focus = None; + if (wm == None) return 0; + int revert; + XGetInputFocus(d, &focus, &revert); + return focus == wm; +} + +char *cthulhu_i3_socket(void) { + Display *d = XOpenDisplay(NULL); + if (!d) return NULL; + cthulhu_x11_begin_read(d); + char *socket = NULL; + if (i3_support_window(d) != None) { + unsigned char *path = i3_property(d, DefaultRootWindow(d), "I3_SOCKET_PATH", XInternAtom(d, "UTF8_STRING", False), 8); + if (path) { socket = strdup((char *)path); XFree(path); } + } + if (cthulhu_x11_end_read(d)) { free(socket); socket = NULL; } + XCloseDisplay(d); + return socket; +} diff --git a/tests/native_i3.py b/tests/native_i3.py index 05fccde..4ea4991 100644 --- a/tests/native_i3.py +++ b/tests/native_i3.py @@ -34,7 +34,7 @@ def inside(): result = subprocess.run(["i3", "--get-socketpath"], env=env, capture_output=True, text=True) return result.returncode == 0 and Path(result.stdout.strip()).is_socket() wait_for(ready, "private i3 did not start") - subprocess.run(["go", "test", "./internal/i3ipc", "-run", "^TestNativeI3Events$", "-count=1", "-v", "-timeout=30s"], + subprocess.run(["go", "test", "./internal/i3ipc", "-run", "^TestNativeI3(Events|EmptyWorkspaceFocus)$", "-count=1", "-v", "-timeout=30s"], cwd=ROOT, env=dict(env, CTHULHU_NATIVE_I3="1"), check=True, timeout=60) finally: wm.terminate()