Merge branch 'testing' of https://git.stormux.org/storm/cthulhu into testing

This commit is contained in:
Storm Dragon committed 2026-11-07 14:43:12 -05:00
commit c925344c41
8 files changed
+254 -9

No files matched your search

+10 -5
View File
@@ -58,9 +58,11 @@ gint cthulhu_document_action_priority(AtspiAccessible *source, const gchar *name
return 0; return 0;
} }
// Some toolkits expose an edit field's label only as a relation, leaving its // Some toolkits expose an edit field's or menu item's label only as a
// accessible name empty. Follow direct targets only, with a shared query and // relation, leaving its accessible name empty. GTK 4 popover menu items point
// output budget; never infer a label from neighbouring controls or field text. // 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) { static gchar *cthulhu_text_control_label(AtspiAccessible *source) {
GArray *relations = atspi_accessible_get_relation_set(source, NULL); GArray *relations = atspi_accessible_get_relation_set(source, NULL);
if (relations == NULL) return NULL; if (relations == NULL) return NULL;
@@ -79,7 +81,8 @@ static gchar *cthulhu_text_control_label(AtspiAccessible *source) {
if (role != ATSPI_ROLE_PASSWORD_TEXT) { if (role != ATSPI_ROLE_PASSWORD_TEXT) {
text = atspi_accessible_get_name(target, NULL); text = atspi_accessible_get_name(target, NULL);
if ((text == NULL || *text == '\0') && 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); g_free(text);
AtspiText *labelText = atspi_accessible_get_text(target); AtspiText *labelText = atspi_accessible_get_text(target);
if (labelText != NULL) { 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') && 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, "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); g_free(snapshot.name);
snapshot.name = cthulhu_text_control_label(source); snapshot.name = cthulhu_text_control_label(source);
} }
@@ -70,6 +70,8 @@ func TestNativeEditFieldLabels(t *testing.T) {
{"named", "Explicit name"}, {"named", "Explicit name"},
{"unlabelled", ""}, {"unlabelled", ""},
{"protected", "Password"}, {"protected", "Password"},
{"invalid-label", "Fallback label"},
{"password-target", ""},
} { } {
t.Run(test.id, func(t *testing.T) { t.Run(test.id, func(t *testing.T) {
snapshot, ok := fields[test.id] snapshot, ok := fields[test.id]
+11 -1
View File
@@ -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)} frame := Rectangle{X: int32(client.FrameX), Y: int32(client.FrameY), Width: int32(client.FrameWidth), Height: int32(client.FrameHeight)}
return bounds == inner || bounds == outer || return bounds == inner || bounds == outer ||
frame.Valid() && bounds == frame || 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 // Some Chromium windows report AT-SPI extents in logical pixels while X11
+30
View File
@@ -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) { func TestForegroundWindowCorrelationSteamBigPictureScale(t *testing.T) {
client := xorg.Foreground{Known: true, Window: 83886130, PID: 111, X: 0, Y: 0, Width: 1920, Height: 1080, 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} OuterX: 0, OuterY: 0, OuterWidth: 1920, OuterHeight: 1080}
@@ -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)
})
}
}
+34
View File
@@ -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", ["<Control>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)
+24
View File
@@ -31,5 +31,29 @@ field("multiple", ["Account", "identifier"])
field("named", ["Other label"], name="Explicit name") field("named", ["Other label"], name="Explicit name")
field("unlabelled", []) field("unlabelled", [])
field("protected", ["Password"], password=True) 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() window.show_all()
Gtk.main() Gtk.main()
+58 -3
View File
@@ -26,6 +26,9 @@ def run_inside():
if os.environ.get("CTHULHU_NATIVE_EDIT_LABELS") == "1": if os.environ.get("CTHULHU_NATIVE_EDIT_LABELS") == "1":
run_edit_label_probe() run_edit_label_probe()
return 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_PID") == "1":
if os.environ.get("CTHULHU_FOREGROUND_PROXY") == "1": if os.environ.get("CTHULHU_FOREGROUND_PROXY") == "1":
run_proxy_probe() run_proxy_probe()
@@ -58,11 +61,20 @@ def run_inside():
def run_edit_label_probe(): def run_edit_label_probe():
fixture = subprocess.Popen([sys.executable, str(Path(__file__).with_name("gtk_edit_labels.py"))]) fixture = subprocess.Popen([sys.executable, str(Path(__file__).with_name("gtk_edit_labels.py"))])
try: try:
import pyatspi import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
def find_window(): def find_window():
return any(window.name == "Edit label probe" desktop = Atspi.get_desktop(0)
for app in pyatspi.Registry.getDesktop(0) for window in app) 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") wait_for(find_window, "edit label probe missing")
environment = dict(os.environ, GOCACHE="/tmp/cthulhu-go-cache", GOPROXY="off") environment = dict(os.environ, GOCACHE="/tmp/cthulhu-go-cache", GOPROXY="off")
@@ -76,6 +88,49 @@ def run_edit_label_probe():
fixture.wait(timeout=3) 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(): def run_thunar_probe():
with tempfile.TemporaryDirectory(prefix="cthulhu-thunar-") as temporary: with tempfile.TemporaryDirectory(prefix="cthulhu-thunar-") as temporary:
home = Path(temporary) home = Path(temporary)