diff --git a/internal/atspi/adapter_capture.c b/internal/atspi/adapter_capture.c index 80ad5d6..a38f61f 100644 --- a/internal/atspi/adapter_capture.c +++ b/internal/atspi/adapter_capture.c @@ -58,9 +58,11 @@ gint cthulhu_document_action_priority(AtspiAccessible *source, const gchar *name return 0; } -// Some toolkits expose an edit field's label only as a relation, leaving its -// accessible name empty. Follow direct targets only, with a shared query and -// output budget; never infer a label from neighbouring controls or field text. +// Some toolkits expose an edit field's or menu item's label only as a +// relation, leaving its accessible name empty. GTK 4 popover menu items point +// at an internal label whose role is invalid, so read that target's text too. +// Follow direct targets only, with a shared query and output budget; never +// infer a label from neighbouring controls or field text. static gchar *cthulhu_text_control_label(AtspiAccessible *source) { GArray *relations = atspi_accessible_get_relation_set(source, NULL); if (relations == NULL) return NULL; @@ -79,7 +81,8 @@ static gchar *cthulhu_text_control_label(AtspiAccessible *source) { if (role != ATSPI_ROLE_PASSWORD_TEXT) { text = atspi_accessible_get_name(target, NULL); if ((text == NULL || *text == '\0') && - (role == ATSPI_ROLE_LABEL || role == ATSPI_ROLE_STATIC)) { + (role == ATSPI_ROLE_LABEL || role == ATSPI_ROLE_STATIC || + role == ATSPI_ROLE_INVALID)) { g_free(text); AtspiText *labelText = atspi_accessible_get_text(target); if (labelText != NULL) { @@ -504,7 +507,9 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt } if (!snapshot.defunct && (snapshot.name == NULL || *snapshot.name == '\0') && (g_strcmp0(snapshot.role, "text") == 0 || g_strcmp0(snapshot.role, "entry") == 0 || - g_strcmp0(snapshot.role, "password text") == 0)) { + g_strcmp0(snapshot.role, "password text") == 0 || g_strcmp0(snapshot.role, "menu item") == 0 || + g_strcmp0(snapshot.role, "check menu item") == 0 || + g_strcmp0(snapshot.role, "radio menu item") == 0)) { g_free(snapshot.name); snapshot.name = cthulhu_text_control_label(source); } diff --git a/internal/atspi/edit_labels_native_test.go b/internal/atspi/edit_labels_native_test.go index 0f8d627..a2327d7 100644 --- a/internal/atspi/edit_labels_native_test.go +++ b/internal/atspi/edit_labels_native_test.go @@ -70,6 +70,8 @@ func TestNativeEditFieldLabels(t *testing.T) { {"named", "Explicit name"}, {"unlabelled", ""}, {"protected", "Password"}, + {"invalid-label", "Fallback label"}, + {"password-target", ""}, } { t.Run(test.id, func(t *testing.T) { snapshot, ok := fields[test.id] diff --git a/internal/atspi/foreground.go b/internal/atspi/foreground.go index 9c75be2..f9dfe0a 100644 --- a/internal/atspi/foreground.go +++ b/internal/atspi/foreground.go @@ -49,7 +49,17 @@ func foregroundWindowMatches(client xorg.Foreground, pid int, bounds Rectangle) frame := Rectangle{X: int32(client.FrameX), Y: int32(client.FrameY), Width: int32(client.FrameWidth), Height: int32(client.FrameHeight)} return bounds == inner || bounds == outer || frame.Valid() && bounds == frame || - scaledWindowMatches(bounds, inner) || scaledWindowMatches(bounds, outer) || scaledWindowMatches(bounds, frame) + scaledWindowMatches(bounds, inner) || scaledWindowMatches(bounds, outer) || scaledWindowMatches(bounds, frame) || + unpositionedWindowMatches(client, pid, bounds, inner) +} + +// Some GTK 4 X11 frames report screen extents with a 0,0 origin wherever +// the window really is. Accept such a frame only when both sides name the +// same process and the size equals the X11 client +// exactly; a dialog or sibling of a different size still cannot claim it. +func unpositionedWindowMatches(client xorg.Foreground, pid int, bounds, inner Rectangle) bool { + return client.PID > 0 && pid == client.PID && bounds.X == 0 && bounds.Y == 0 && + bounds.Width == inner.Width && bounds.Height == inner.Height } // 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 55a15c5..530864d 100644 --- a/internal/atspi/foreground_test.go +++ b/internal/atspi/foreground_test.go @@ -36,6 +36,36 @@ func TestForegroundWindowCorrelation(t *testing.T) { } } +func TestForegroundUnpositionedGTK4Correlation(t *testing.T) { + // GTK 4 on X11 (seen on i3) reports its frame at 0,0 with the client's size. + client := xorg.Foreground{Known: true, Window: 35651592, PID: 215379, + X: 700, Y: 449, Width: 520, Height: 322, OuterX: 698, OuterY: 427, OuterWidth: 524, OuterHeight: 346, + FrameX: 698, FrameY: 427, FrameWidth: 524, FrameHeight: 346} + for _, test := range []struct { + name string + client xorg.Foreground + pid int + bounds Rectangle + want bool + }{ + {"gtk4 frame at origin", client, 215379, Rectangle{0, 0, 520, 322}, true}, + {"other process", client, 215380, Rectangle{0, 0, 520, 322}, false}, + {"unknown accessible process", client, 0, Rectangle{0, 0, 520, 322}, false}, + {"unknown X11 process", xorg.Foreground{Known: true, Window: 35651592, + X: 700, Y: 449, Width: 520, Height: 322}, 215379, Rectangle{0, 0, 520, 322}, false}, + {"same process different size", client, 215379, Rectangle{0, 0, 400, 300}, false}, + {"same process scaled size", client, 215379, Rectangle{0, 0, 260, 161}, false}, + {"same process outer size at origin", client, 215379, Rectangle{0, 0, 524, 346}, false}, + {"same size elsewhere", client, 215379, Rectangle{10, 0, 520, 322}, false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := foregroundWindowMatches(test.client, test.pid, test.bounds); got != test.want { + t.Fatalf("match = %v, want %v", got, test.want) + } + }) + } +} + func TestForegroundWindowCorrelationSteamBigPictureScale(t *testing.T) { client := xorg.Foreground{Known: true, Window: 83886130, PID: 111, X: 0, Y: 0, Width: 1920, Height: 1080, OuterX: 0, OuterY: 0, OuterWidth: 1920, OuterHeight: 1080} diff --git a/internal/atspi/gtk4_menu_labels_native_test.go b/internal/atspi/gtk4_menu_labels_native_test.go new file mode 100644 index 0000000..98fa2d2 --- /dev/null +++ b/internal/atspi/gtk4_menu_labels_native_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi_test + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +// GTK 4 popover menu items leave Name empty and point LABELLED_BY at an +// internal label whose role is invalid; only its text holds the item's label. +func TestNativeGTK4MenuItemLabels(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_GTK4_MENU_LABELS") != "1" { + t.Skip("requires isolated GTK 4 menu label fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Error(err) + } + }() + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + root, err := tree.Root() + if err != nil { + t.Fatal(err) + } + var items []atspi.Snapshot + var walk func(atspi.ObjectReference) + walk = func(ref atspi.ObjectReference) { + snapshot, err := tree.Resolve(ref) + if err != nil { + t.Fatal(err) + } + if snapshot.Role == "menu item" || snapshot.Role == "check menu item" || snapshot.Role == "radio menu item" { + items = append(items, snapshot) + } + child, ok, err := tree.FirstChild(ref) + if err != nil { + t.Fatal(err) + } + for ok { + walk(child) + child, ok, err = tree.NextSibling(child) + if err != nil { + t.Fatal(err) + } + } + } + walk(root) + for _, want := range []string{"New Message", "Add Account…", "Group into Conversations", "All Messages"} { + t.Run(want, func(t *testing.T) { + for _, item := range items { + if item.Name == want { + if spoken := presentation.FocusText(item); !strings.Contains(spoken, want) { + t.Errorf("speech %q omits label %q", spoken, want) + } + return + } + } + names := make([]string, 0, len(items)) + for _, item := range items { + names = append(names, item.Role+": "+item.Name) + } + t.Errorf("no menu item named %q; captured %q", want, names) + }) + } +} diff --git a/tests/gtk4_menu_labels.py b/tests/gtk4_menu_labels.py new file mode 100644 index 0000000..ed1dbac --- /dev/null +++ b/tests/gtk4_menu_labels.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""A GTK 4 popover menu, whose items expose their label only through a relation.""" +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk + + +def activate(app): + window = Gtk.ApplicationWindow(application=app, title="GTK 4 menu label probe") + for name in ("compose", "add-account", "grouping"): + action = (Gio.SimpleAction.new_stateful(name, None, GLib.Variant.new_boolean(True)) + if name == "grouping" else Gio.SimpleAction.new(name, None)) + window.add_action(action) + app.set_accels_for_action("win.compose", ["n"]) + window.add_action(Gio.SimpleAction.new_stateful("filter", GLib.VariantType.new("s"), + GLib.Variant.new_string("all"))) + menu = Gio.Menu() + menu.append("_New Message", "win.compose") + menu.append("_Add Account…", "win.add-account") + menu.append("Group into _Conversations", "win.grouping") + menu.append("_All Messages", "win.filter::all") + button = Gtk.MenuButton(label="File", menu_model=menu) + window.set_child(button) + window.present() + GLib.timeout_add(300, lambda: (button.popup(), False)[1]) + + +app = Gtk.Application(application_id="org.stormux.CthulhuGtk4MenuProbe") +app.connect("activate", activate) +app.run(None) diff --git a/tests/gtk_edit_labels.py b/tests/gtk_edit_labels.py index 147219f..ce18ec4 100644 --- a/tests/gtk_edit_labels.py +++ b/tests/gtk_edit_labels.py @@ -31,5 +31,29 @@ field("multiple", ["Account", "identifier"]) field("named", ["Other label"], name="Explicit name") field("unlabelled", []) field("protected", ["Password"], password=True) + +# Exercise the role-invalid Text fallback used by GTK 4, and ensure that a +# password relation target cannot contribute either its name or its contents. +invalid_label = Gtk.Label(label="Fallback label") +invalid_label.get_accessible().set_role(Atk.Role.INVALID) +invalid_label.get_accessible().set_name("") +box.pack_start(invalid_label, False, False, 0) +invalid_entry = Gtk.Entry() +invalid_entry.get_accessible().set_name("") +invalid_entry.get_accessible().set_description("invalid-label") +invalid_entry.get_accessible().add_relationship(Atk.RelationType.LABELLED_BY, + invalid_label.get_accessible()) +box.pack_start(invalid_entry, False, False, 0) +password_label = Gtk.Entry() +password_label.set_visibility(False) +password_label.set_text("secret relation value") +password_label.get_accessible().set_name("secret relation name") +box.pack_start(password_label, False, False, 0) +safe_entry = Gtk.Entry() +safe_entry.get_accessible().set_name("") +safe_entry.get_accessible().set_description("password-target") +safe_entry.get_accessible().add_relationship(Atk.RelationType.LABELLED_BY, + password_label.get_accessible()) +box.pack_start(safe_entry, False, False, 0) window.show_all() Gtk.main() diff --git a/tests/native_atspi.py b/tests/native_atspi.py index 335e8c3..076fdbf 100644 --- a/tests/native_atspi.py +++ b/tests/native_atspi.py @@ -26,6 +26,9 @@ def run_inside(): if os.environ.get("CTHULHU_NATIVE_EDIT_LABELS") == "1": run_edit_label_probe() return + if os.environ.get("CTHULHU_NATIVE_GTK4_MENU_LABELS") == "1": + run_gtk4_menu_label_probe() + return if os.environ.get("CTHULHU_FOREGROUND_PID") == "1": if os.environ.get("CTHULHU_FOREGROUND_PROXY") == "1": run_proxy_probe() @@ -58,11 +61,20 @@ def run_inside(): def run_edit_label_probe(): fixture = subprocess.Popen([sys.executable, str(Path(__file__).with_name("gtk_edit_labels.py"))]) try: - import pyatspi + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi def find_window(): - return any(window.name == "Edit label probe" - for app in pyatspi.Registry.getDesktop(0) for window in app) + desktop = Atspi.get_desktop(0) + for i in range(desktop.get_child_count()): + app = desktop.get_child_at_index(i) + for j in range(app.get_child_count() if app else 0): + window = app.get_child_at_index(j) + if window is not None and window.get_name() == "Edit label probe": + return True + return False wait_for(find_window, "edit label probe missing") environment = dict(os.environ, GOCACHE="/tmp/cthulhu-go-cache", GOPROXY="off") @@ -76,6 +88,49 @@ def run_edit_label_probe(): fixture.wait(timeout=3) +def run_gtk4_menu_label_probe(): + fixture = subprocess.Popen([sys.executable, str(Path(__file__).with_name("gtk4_menu_labels.py"))], + env=dict(os.environ, GTK_A11Y="atspi")) + try: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + def showing_menu_item(node, depth=0): + if node is None or depth > 40: + return False + if node.get_role_name() in ("menu item", "check menu item", "radio menu item") and \ + node.get_state_set().contains(Atspi.StateType.SHOWING): + return True + return any(showing_menu_item(node.get_child_at_index(i), depth + 1) + for i in range(node.get_child_count())) + + def menu_open(): + desktop = Atspi.get_desktop(0) + for i in range(desktop.get_child_count()): + app = desktop.get_child_at_index(i) + for j in range(app.get_child_count() if app else 0): + window = app.get_child_at_index(j) + if window is not None and window.get_name() == "GTK 4 menu label probe": + try: + return showing_menu_item(window) + except Exception: # noqa: BLE001 - tree still settling + return False + return False + + wait_for(menu_open, "GTK 4 menu probe missing") + environment = dict(os.environ, GOCACHE="/tmp/cthulhu-go-cache", GOPROXY="off") + subprocess.run( + ["go", "test", "./internal/atspi", "-run", "^TestNativeGTK4MenuItemLabels$", + "-count=1", "-timeout=25s", "-v"], + cwd=Path(__file__).resolve().parents[1], env=environment, check=True, + ) + finally: + fixture.terminate() + fixture.wait(timeout=3) + + def run_thunar_probe(): with tempfile.TemporaryDirectory(prefix="cthulhu-thunar-") as temporary: home = Path(temporary)