Resume Cthulhu on empty i3 workspaces
This commit is contained in:
1 parent
7752c4e566
commit
d5f8a392cf
8 files changed
+191
-44
No files matched your search
@@ -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.
|
||||||
@@ -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):
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in new issue
Block a user