Resume Cthulhu on empty i3 workspaces

This commit is contained in:
ak47 committed 2026-11-07 19:49:00 -05:00
1 parent 7752c4e566
commit d5f8a392cf
8 files changed
+191 -44

No files matched your search

+19 -1
View File
@@ -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 adopts the current XKB lock state before treating Caps Lock as the reader
modifier again. 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: Focused checks:
```sh ```sh
go test ./internal/app -run 'Test(XTerm|DelegatedInput)' -count=1 go test ./internal/app -run 'Test(XTerm|DelegatedInput)' -count=1
python3 tests/native_i3.py
python3 tests/native_x11.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 Control, or Shift when using the “all keys” interruption setting. Expect
Cthulhu's current announcement and queued continuation to stop promptly. Cthulhu's current announcement and queued continuation to stop promptly.
Fenrir must continue receiving terminal keys, with no Cthulhu key echo. 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.
+102
View File
@@ -15,9 +15,93 @@ import (
"testing" "testing"
"time" "time"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/platform/xorg" "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) { func TestNativeI3Events(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_I3") != "1" { if os.Getenv("CTHULHU_NATIVE_I3") != "1" {
t.Skip("requires private i3 harness") t.Skip("requires private i3 harness")
@@ -220,4 +304,22 @@ func TestNativeI3Unavailable(t *testing.T) {
if path := xorg.I3Socket(); path != "" { if path := xorg.I3Socket(); path != "" {
t.Fatalf("activated without i3: %q", 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):
}
} }
+1 -1
View File
@@ -312,7 +312,7 @@ int cthulhu_focus_metadata_unchecked(Display *display, SteamFocusSample *steam)
steam->window = window; steam->window = window;
} }
if (window == None || window == root) { if (window == None || window == root) {
return 0; return cthulhu_i3_no_client_focus(display) ? 2 : 0;
} }
Atom classAtom = XInternAtom(display, "WM_CLASS", False); Atom classAtom = XInternAtom(display, "WM_CLASS", False);
+3
View File
@@ -37,6 +37,9 @@ typedef struct { int known, pid, steam, innerFocus; Window window, focus; } Stea
typedef struct { int xterm; SteamFocusSample steam; } CommandFocusSample; typedef struct { int xterm; SteamFocusSample steam; } CommandFocusSample;
int cthulhu_active_window_is_xterm(void); 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); int cthulhu_display_active_window_xterm_match(Display *display);
Foreground read_foreground(Display *display); Foreground read_foreground(Display *display);
int cthulhu_xres_client_pids_supported(Display *display); int cthulhu_xres_client_pids_supported(Display *display);
@@ -136,7 +136,7 @@ func (owner *commandOwner) publishFocus() steamFocusSample {
// Native xterm focus vetoes delayed accessibility requests to restore grabs. // Native xterm focus vetoes delayed accessibility requests to restore grabs.
// lastFocusMatch belongs to this owner and survives unknown focus samples; // 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 { func (owner *commandOwner) commandGrabsAllowed() bool {
return owner.lastFocusMatch != 1 && owner.listener.commandsEnabled.Load() && !owner.listener.secure.Load() return owner.lastFocusMatch != 1 && owner.listener.commandsEnabled.Load() && !owner.listener.secure.Load()
} }
+1 -40
View File
@@ -6,46 +6,7 @@ package xorg
/* /*
#include "commands_native.h" #include "commands_native.h"
#include <X11/Xlib.h>
#include <X11/Xatom.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h>
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" import "C"
@@ -61,7 +22,7 @@ func I3Socket() string {
if os.Getenv("DISPLAY") == "" { if os.Getenv("DISPLAY") == "" {
return "" return ""
} }
path := C.i3_socket() path := C.cthulhu_i3_socket()
if path == nil { if path == nil {
return "" return ""
} }
+63
View File
@@ -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 <X11/Xatom.h>
#include <stdlib.h>
#include <string.h>
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;
}
+1 -1
View File
@@ -34,7 +34,7 @@ def inside():
result = subprocess.run(["i3", "--get-socketpath"], env=env, capture_output=True, text=True) result = subprocess.run(["i3", "--get-socketpath"], env=env, capture_output=True, text=True)
return result.returncode == 0 and Path(result.stdout.strip()).is_socket() return result.returncode == 0 and Path(result.stdout.strip()).is_socket()
wait_for(ready, "private i3 did not start") 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) cwd=ROOT, env=dict(env, CTHULHU_NATIVE_I3="1"), check=True, timeout=60)
finally: finally:
wm.terminate() wm.terminate()