From 1c12bc6636acad4cb3bd533644e4debff26e74ba Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 1 Nov 2026 15:48:33 -0500 Subject: [PATCH] The initial Slint problem was larger than thought. Hopefully a more universal fix. --- docs/testing/background-events.md | 30 ++++++ internal/app/event_accessibility.go | 3 +- internal/atspi/foreground.go | 4 +- internal/atspi/foreground_test.go | 27 ++++++ internal/platform/xorg/commands_foreground.go | 6 +- internal/platform/xorg/commands_native.c | 52 ++++++++++ internal/platform/xorg/commands_native.h | 1 + .../xorg/frame_foreground_native_test.go | 95 +++++++++++++++++++ tests/native_ownership.py | 9 +- tests/x11_frame_fixture.py | 73 ++++++++++++++ 10 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 internal/platform/xorg/frame_foreground_native_test.go create mode 100644 tests/x11_frame_fixture.py diff --git a/docs/testing/background-events.md b/docs/testing/background-events.md index 0111618..f285c5c 100644 --- a/docs/testing/background-events.md +++ b/docs/testing/background-events.md @@ -79,3 +79,33 @@ ready to replace the reader. Preserve a fresh log if lag or a missing announcement remains, with the time and the key or update that triggered it. Debug logs can contain private text. + +## Window frame geometry + +GTK can report the window manager's enclosing frame while X11's active client +reports only its content area. Slint/i3 exposed this as an active, showing +1916×994 accessible frame at (0,0) rejected against a 1912×974 client at (2,18). +Missing or stale `_NET_FRAME_EXTENTS` can make the advertised outer bounds +identical to those client bounds. + +Foreground matching also accepts the actual immediate enclosing X11 frame. +It must contain the client, have bounded decoration edges, and contain no other +managed client. The desktop root, a parent that is itself a managed client, +and shared containers are excluded. Existing process, active/showing state, +iconification, foreground epoch and same-process sibling checks still apply. +This covers frame/client discrepancies across applications; arbitrary incorrect +AT-SPI geometry still requires diagnosis. Rejected activation diagnostics now +include `x_frame_bounds` alongside client and advertised outer bounds. + +```sh +python3 tests/native_test_session.py env CTHULHU_NATIVE_FRAME_FOREGROUND=1 CTHULHU_NATIVE_GRAB_FOREGROUND=1 go test ./internal/platform/xorg -run '^TestNative(ReparentedFrameForeground|InputOnlyGrabForeground)$' -count=1 -v +``` + +The frame fixture covers missing, zero and stale extents, borders, moving, +resizing, switching to a sibling frame, shared containers and empty workspaces. +On Slint, listen while switching between two windows from one application, +another application's window, dialogs and menus. Move and resize them, switch +away to an empty workspace and return, and check preferences again. Confirm +speech and Braille follow the foreground control and background activity does +not take focus. Keep the first rejected activation from a fresh debug log if a +window remains silent; private X11 results do not prove affected-host acceptance. diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index 6406f81..fa3f5e3 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -152,7 +152,8 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) { "active", check.Active, "showing", check.Showing, "iconified", check.Iconified, "accessible_bounds", check.Bounds, "x_bounds", [4]int{client.X, client.Y, client.Width, client.Height}, - "x_outer_bounds", [4]int{client.OuterX, client.OuterY, client.OuterWidth, client.OuterHeight}) + "x_outer_bounds", [4]int{client.OuterX, client.OuterY, client.OuterWidth, client.OuterHeight}, + "x_frame_bounds", [4]int{client.FrameX, client.FrameY, client.FrameWidth, client.FrameHeight}) } else { logger.Debug("ignored accessibility event outside X11 foreground", "type", event.Type, "bus", event.Window.BusName, "window_path", event.Window.Path, diff --git a/internal/atspi/foreground.go b/internal/atspi/foreground.go index 6d3a3b3..9c75be2 100644 --- a/internal/atspi/foreground.go +++ b/internal/atspi/foreground.go @@ -46,8 +46,10 @@ func foregroundWindowMatches(client xorg.Foreground, pid int, bounds Rectangle) } inner := Rectangle{X: int32(client.X), Y: int32(client.Y), Width: int32(client.Width), Height: int32(client.Height)} outer := Rectangle{X: int32(client.OuterX), Y: int32(client.OuterY), Width: int32(client.OuterWidth), Height: int32(client.OuterHeight)} + frame := Rectangle{X: int32(client.FrameX), Y: int32(client.FrameY), Width: int32(client.FrameWidth), Height: int32(client.FrameHeight)} return bounds == inner || bounds == outer || - scaledWindowMatches(bounds, inner) || scaledWindowMatches(bounds, outer) + frame.Valid() && bounds == frame || + scaledWindowMatches(bounds, inner) || scaledWindowMatches(bounds, outer) || scaledWindowMatches(bounds, frame) } // Some Chromium windows report AT-SPI extents in logical pixels while X11 diff --git a/internal/atspi/foreground_test.go b/internal/atspi/foreground_test.go index 20aca0b..55a15c5 100644 --- a/internal/atspi/foreground_test.go +++ b/internal/atspi/foreground_test.go @@ -44,6 +44,33 @@ func TestForegroundWindowCorrelationSteamBigPictureScale(t *testing.T) { } } +func TestForegroundReparentedFrameCorrelation(t *testing.T) { + client := xorg.Foreground{Known: true, Window: 45, PID: 123, + X: 2, Y: 18, Width: 1912, Height: 974, OuterX: 2, OuterY: 18, OuterWidth: 1912, OuterHeight: 974, + FrameX: 0, FrameY: 0, FrameWidth: 1916, FrameHeight: 994} + for _, test := range []struct { + name string + pid int + bounds Rectangle + want bool + }{ + {"actual WM frame", 123, Rectangle{0, 0, 1916, 994}, true}, + {"client still accepted", 123, Rectangle{2, 18, 1912, 974}, true}, + {"other process same frame size", 124, Rectangle{0, 0, 1916, 994}, false}, + {"same process other frame", 123, Rectangle{1920, 0, 1916, 994}, false}, + {"same process smaller dialog", 123, Rectangle{20, 30, 400, 300}, false}, + {"scaled frame same origin", 123, Rectangle{0, 0, 958, 497}, true}, + {"scaled frame wrong origin", 123, Rectangle{1, 0, 958, 497}, false}, + {"scaled frame wrong aspect", 123, Rectangle{0, 0, 958, 400}, false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := foregroundWindowMatches(client, test.pid, test.bounds); got != test.want { + t.Fatalf("match = %v, want %v", got, test.want) + } + }) + } +} + func TestPreferencesForegroundIdentity(t *testing.T) { // Slint/i3 reports GTK's frame and the X11 client with different borders. client := xorg.Foreground{Known: true, Window: 45, PID: 123, diff --git a/internal/platform/xorg/commands_foreground.go b/internal/platform/xorg/commands_foreground.go index 5b5c0dd..e569016 100644 --- a/internal/platform/xorg/commands_foreground.go +++ b/internal/platform/xorg/commands_foreground.go @@ -22,6 +22,9 @@ type Foreground struct { PropertyPID int X, Y, Width, Height int OuterX, OuterY, OuterWidth, OuterHeight int + // Frame bounds come from the client's enclosing X11 window, independently + // of _NET_FRAME_EXTENTS. Zero dimensions mean no unambiguous frame exists. + FrameX, FrameY, FrameWidth, FrameHeight int } // ForegroundReader has no goroutine. Its native event-loop owner opens, reads, @@ -60,7 +63,8 @@ func (reader *ForegroundReader) Read() Foreground { pid = reader.clientPID } return Foreground{Known: value.known != 0, Window: window, PID: pid, PropertyPID: propertyPID, - X: int(value.x), Y: int(value.y), Width: int(value.width), Height: int(value.height), OuterX: int(value.outerX), OuterY: int(value.outerY), OuterWidth: int(value.outerWidth), OuterHeight: int(value.outerHeight)} + X: int(value.x), Y: int(value.y), Width: int(value.width), Height: int(value.height), OuterX: int(value.outerX), OuterY: int(value.outerY), OuterWidth: int(value.outerWidth), OuterHeight: int(value.outerHeight), + FrameX: int(value.frameX), FrameY: int(value.frameY), FrameWidth: int(value.frameWidth), FrameHeight: int(value.frameHeight)} } func (reader *ForegroundReader) Close() { if reader != nil { diff --git a/internal/platform/xorg/commands_native.c b/internal/platform/xorg/commands_native.c index ccdc63c..2758ff7 100644 --- a/internal/platform/xorg/commands_native.c +++ b/internal/platform/xorg/commands_native.c @@ -175,6 +175,57 @@ PointerPosition cthulhu_read_pointer(Display *display) { if (cthulhu_x11_end_read(display)) result = (PointerPosition){0}; return result; } +// Resolve only the client's immediate enclosing frame. Some WMs omit or leave +// stale _NET_FRAME_EXTENTS, while GTK reports the real reparenting frame. +// A container with another managed client is not this client's decoration. +// The caller holds the Xlib error guard throughout these read-only queries. +static void cthulhu_read_frame_bounds(Display *display, Window window, Foreground *result) { + Window root, parent, *children = NULL; + unsigned int count = 0; + if (!XQueryTree(display, window, &root, &parent, &children, &count)) return; + if (children) XFree(children); + if (parent == None || parent == root) return; + + XWindowAttributes attributes; + if (!XGetWindowAttributes(display, parent, &attributes) || + attributes.class != InputOutput || attributes.override_redirect || + attributes.map_state != IsViewable) return; + Atom stateAtom = XInternAtom(display, "WM_STATE", False); + Atom type = None; int format = 0; unsigned long items = 0; + unsigned char *data = cthulhu_window_property(display, parent, stateAtom, &type, &format, &items); + if (data) XFree(data); + if (type != None) return; + + Window treeRoot, frameParent; + children = NULL; + if (!XQueryTree(display, parent, &treeRoot, &frameParent, &children, &count)) return; + int unambiguous = count <= 64, foundClient = 0; + for (unsigned int index = 0; unambiguous && index < count; index++) { + if (children[index] == window) { foundClient = 1; continue; } + data = cthulhu_window_property(display, children[index], stateAtom, &type, &format, &items); + if (data) XFree(data); + if (type != None) unambiguous = 0; + } + if (children) XFree(children); + if (!unambiguous || !foundClient) return; + + int x, y; + Window child; + if (!XTranslateCoordinates(display, parent, root, 0, 0, &x, &y, &child)) return; + int border = attributes.border_width; + x -= border; y -= border; + int width = attributes.width + 2 * border, height = attributes.height + 2 * border; + // Decorations must enclose the client and remain bounded like the EWMH + // extents. Never turn an unrelated workspace into a frame match. + int left = result->x - x, top = result->y - y; + int right = x + width - result->x - result->width; + int bottom = y + height - result->y - result->height; + if (left < 0 || top < 0 || right < 0 || bottom < 0 || + left >= 1024 || top >= 1024 || right >= 1024 || bottom >= 1024) return; + result->frameX = x; result->frameY = y; + result->frameWidth = width; result->frameHeight = height; +} + // Read-only metadata connection, owned by the caller's native event thread. // All requests which can race window destruction share the Xlib error guard. Foreground read_foreground(Display *display) { @@ -227,6 +278,7 @@ Foreground read_foreground(Display *display) { } } if (data) XFree(data); + cthulhu_read_frame_bounds(display, window, &result); data=cthulhu_window_property(display,window,XInternAtom(display,"_NET_WM_PID",False),&type,&format,&items); if (data && type==XA_CARDINAL && format==32 && items==1) result.pid=(int)((unsigned long *)data)[0]; if (data) XFree(data); diff --git a/internal/platform/xorg/commands_native.h b/internal/platform/xorg/commands_native.h index 0b28ad7..1c53fde 100644 --- a/internal/platform/xorg/commands_native.h +++ b/internal/platform/xorg/commands_native.h @@ -27,6 +27,7 @@ typedef struct { typedef struct { int pressed, code, symbol, textSymbol, modifiers, locks; unsigned long timestamp; char text[64]; } CommandKey; typedef struct { int known, pid, x, y, width, height, outerX, outerY, outerWidth, outerHeight; + int frameX, frameY, frameWidth, frameHeight; unsigned long window; } Foreground; typedef struct { int known, pid, x, y; unsigned long window; } PointerPosition; diff --git a/internal/platform/xorg/frame_foreground_native_test.go b/internal/platform/xorg/frame_foreground_native_test.go new file mode 100644 index 0000000..044e7ea --- /dev/null +++ b/internal/platform/xorg/frame_foreground_native_test.go @@ -0,0 +1,95 @@ +// 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" + "fmt" + "os" + "os/exec" + "runtime" + "testing" + "time" +) + +func TestNativeReparentedFrameForeground(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_FRAME_FOREGROUND") != "1" { + t.Skip("requires private X11 reparented-window fixture") + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../../tests/x11_frame_fixture.py") + input, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + output, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err := fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { input.Close(); fixture.Wait() }() + scanner := bufio.NewScanner(output) + if !scanner.Scan() { + t.Fatal("frame fixture did not start") + } + var windows map[string]uint64 + if err := json.Unmarshal(scanner.Bytes(), &windows); err != nil { + t.Fatal(err) + } + reader := OpenForegroundReader() + if reader == nil { + t.Fatal("private X11 connection unavailable") + } + defer reader.Close() + for _, test := range []struct { + action string + client string + frame [4]int + }{ + {"client", "client", [4]int{0, 0, 1916, 994}}, + {"zero-extents", "client", [4]int{0, 0, 1916, 994}}, + {"stale-extents", "client", [4]int{0, 0, 1916, 994}}, + {"resize", "client", [4]int{0, 0, 1000, 700}}, + {"move", "client", [4]int{-30, 40, 1916, 994}}, + {"border", "client", [4]int{0, 0, 1920, 998}}, + {"other", "other", [4]int{2000, 0, 500, 400}}, + {"plain", "plain", [4]int{}}, + {"shared", "shared", [4]int{}}, + {"nested", "nested", [4]int{}}, + {"oversized", "oversized", [4]int{}}, + {"overflow", "overflow", [4]int{}}, + {"empty", "empty", [4]int{}}, + {"client", "client", [4]int{0, 0, 1916, 994}}, + } { + t.Run(test.action, func(t *testing.T) { + if _, err := fmt.Fprintln(input, test.action); err != nil { + t.Fatal(err) + } + if !scanner.Scan() || scanner.Text() != "ready" { + t.Fatal("fixture command failed") + } + got := reader.Read() + wantPID := fixture.Process.Pid + if test.client == "empty" { + wantPID = 0 + } + if !got.Known || got.Window != windows[test.client] || got.PID != wantPID { + t.Fatalf("foreground identity changed: %+v", got) + } + frame := [4]int{got.FrameX, got.FrameY, got.FrameWidth, got.FrameHeight} + if frame != test.frame { + t.Fatalf("frame = %v; want %v (foreground: %+v)", frame, test.frame, got) + } + }) + } +} diff --git a/tests/native_ownership.py b/tests/native_ownership.py index 55866ef..c9f3373 100644 --- a/tests/native_ownership.py +++ b/tests/native_ownership.py @@ -174,7 +174,10 @@ def inside(directory): processes.append(subprocess.Popen(["i3", "-c", str(wm_config)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) wait_for(lambda: subprocess.run(["i3-msg", "-t", "get_workspaces"], capture_output=True).returncode == 0, "i3 startup") settings = root / "settings.toml" - settings.write_text('[general]\nenableSpeech = false\nenableSound = false\nkeyboardLayout = 1\n') + settings.write_text('schema-version = 2\nactive-profile = "default"\n' + '[profiles.default.speech]\nenabled = false\n' + '[profiles.default.sound]\nenabled = false\n' + '[profiles.default.keyboard]\nlayout = 1\n') binary = os.environ["CTHULHU_BINARY"] reader = subprocess.Popen([binary, "--config", str(settings), "--debug-file", str(log)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) processes.append(reader) @@ -241,7 +244,7 @@ def inside(directory): reader.terminate() assert reader.wait(timeout=5) == 0, "reader restart shutdown failed" start = len(records()) - reader = subprocess.Popen([binary, "--config", str(settings), "--debug-file", str(log)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + reader = subprocess.Popen([binary, "--config", str(settings), "--debug-file", str(log), "--append"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) processes.append(reader) recovered = wait_record("activated accessible window", start) assert recovered["window_path"] == expected_window, ("recovered inactive same-geometry sibling", recovered, owned) @@ -299,7 +302,7 @@ def inside(directory): reader.terminate() assert reader.wait(timeout=5) == 0, "browser recovery restart shutdown failed" start = len(records()) - reader = subprocess.Popen([binary, "--config", str(settings), "--debug-file", str(log)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + reader = subprocess.Popen([binary, "--config", str(settings), "--debug-file", str(log), "--append"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) processes.append(reader) recovered = wait_record("activated accessible window", start) assert recovered["window_path"] == expected_browser, ("recovered inactive browser", recovered, expected_browser) diff --git a/tests/x11_frame_fixture.py b/tests/x11_frame_fixture.py new file mode 100644 index 0000000..59cdafd --- /dev/null +++ b/tests/x11_frame_fixture.py @@ -0,0 +1,73 @@ +"""Reparented client fixtures for private X11 frame geometry tests.""" + +import json +import os +import sys + +from Xlib import X, Xatom, display + + +connection = display.Display() +root = connection.screen().root +activeAtom = connection.intern_atom("_NET_ACTIVE_WINDOW") +extentsAtom = connection.intern_atom("_NET_FRAME_EXTENTS") +stateAtom = connection.intern_atom("WM_STATE") +pidAtom = connection.intern_atom("_NET_WM_PID") + + +def make_window(parent, x, y, width, height, managed=False): + window = parent.create_window(x, y, width, height, 0, + connection.screen().root_depth, + X.InputOutput, X.CopyFromParent) + if managed: + window.change_property(stateAtom, stateAtom, 32, [1, 0]) + window.change_property(pidAtom, Xatom.CARDINAL, 32, [os.getpid()]) + window.map() + return window + + +frame = make_window(root, 0, 0, 1916, 994) +client = make_window(frame, 2, 18, 1912, 974, managed=True) +otherFrame = make_window(root, 2000, 0, 500, 400) +other = make_window(otherFrame, 3, 20, 494, 377, managed=True) +plain = make_window(root, 50, 60, 400, 300, managed=True) +container = make_window(root, 0, 1100, 800, 600) +shared = make_window(container, 2, 18, 396, 580, managed=True) +make_window(container, 402, 18, 396, 580, managed=True) +managedParent = make_window(root, 900, 1100, 500, 400, managed=True) +nested = make_window(managedParent, 2, 18, 496, 380, managed=True) +largeFrame = make_window(root, 0, 2000, 4000, 3000) +oversized = make_window(largeFrame, 1200, 1100, 400, 300, managed=True) +smallFrame = make_window(root, 1000, 2000, 200, 100) +overflow = make_window(smallFrame, 0, 0, 400, 300, managed=True) +windows = dict(client=client, other=other, plain=plain, shared=shared, + nested=nested, oversized=oversized, overflow=overflow) + + +def configure(action): + frame.configure(x=0, y=0, width=1916, height=994, border_width=0) + client.configure(width=1912, height=974) + client.delete_property(extentsAtom) + selected = windows.get(action, client) + if action == "zero-extents": + client.change_property(extentsAtom, Xatom.CARDINAL, 32, [0, 0, 0, 0]) + elif action == "stale-extents": + client.change_property(extentsAtom, Xatom.CARDINAL, 32, [8, 8, 40, 8]) + elif action == "resize": + frame.configure(width=1000, height=700) + client.configure(width=996, height=680) + elif action == "move": + frame.configure(x=-30, y=40) + elif action == "border": + frame.configure(border_width=2) + root.change_property(activeAtom, Xatom.WINDOW, 32, + [0 if action == "empty" else selected.id]) + connection.sync() + + +configure("client") +print(json.dumps({name: window.id for name, window in windows.items()}), flush=True) +for line in sys.stdin: + configure(line.strip()) + print("ready", flush=True) +connection.close()