More work on cthulhu recognizing focused items that should be read.
This commit is contained in:
1 parent
9374b5a2e5
commit
300f4a7504
9 files changed
+456
-9
No files matched your search
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/braille"
|
||||
)
|
||||
|
||||
func TestNativeEmbeddedPanelAppletPresentation(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_PANEL_APPLET") != "1" {
|
||||
t.Skip("requires private Xorg/AT-SPI session")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
adapter := atspi.New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
start := func(args ...string) (io.WriteCloser, string, string) {
|
||||
t.Helper()
|
||||
command := exec.CommandContext(ctx, "/usr/bin/python3", append([]string{"../../tests/gtk_panel_applet.py"}, args...)...)
|
||||
input, err := command.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command.Stderr = os.Stderr
|
||||
if err := command.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { input.Close(); command.Process.Kill(); command.Wait() })
|
||||
line, err := bufio.NewReader(output).ReadString('\n')
|
||||
fields := strings.Fields(line)
|
||||
if err != nil || len(fields) != 3 || fields[0] != "ready" {
|
||||
t.Fatalf("fixture readiness: %q, %v", line, err)
|
||||
}
|
||||
return input, fields[1], fields[2]
|
||||
}
|
||||
host, xid, socketID := start("panel")
|
||||
start("applet", socketID)
|
||||
editor, editorXID, _ := start("editor")
|
||||
key := func(args ...string) {
|
||||
t.Helper()
|
||||
if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("xdotool: %s, %v", output, err)
|
||||
}
|
||||
}
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 128)}
|
||||
frames := make(chan braille.Frame, 128)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
processEventsWithInput(ctx, slog.New(slog.DiscardHandler),
|
||||
outputQueue{speech: speech, braille: browserBrailleProbe{frames: frames}},
|
||||
adapter, nil, nil, 0, nil, nil, nil, nil)
|
||||
}()
|
||||
defer func() { cancel(); <-done }()
|
||||
wait := func(name string) {
|
||||
t.Helper()
|
||||
spoken, displayed := false, false
|
||||
var messages []string
|
||||
for !spoken || !displayed {
|
||||
select {
|
||||
case text := <-speech.spoken:
|
||||
messages = append(messages, text)
|
||||
spoken = spoken || strings.Contains(text, name)
|
||||
case frame := <-frames:
|
||||
displayed = displayed || strings.Contains(frame.Text, name)
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("missing presentation %q: speech=%v braille=%v messages=%q", name, spoken, displayed, messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
for iteration := 0; iteration < 2; iteration++ {
|
||||
key("windowfocus", "--sync", xid)
|
||||
fmt.Fprintln(host, "first")
|
||||
wait("Before applet")
|
||||
key("key", "Tab")
|
||||
wait("Network applet")
|
||||
key("key", "Tab")
|
||||
wait("Applet settings")
|
||||
key("key", "shift+Tab")
|
||||
wait("Network applet")
|
||||
key("key", "Tab")
|
||||
wait("Applet settings")
|
||||
key("key", "Tab")
|
||||
wait("After applet")
|
||||
for _, name := range []string{"Applet settings", "Network applet", "Before applet"} {
|
||||
key("key", "shift+Tab")
|
||||
wait(name)
|
||||
}
|
||||
key("windowfocus", "--sync", editorXID)
|
||||
text := fmt.Sprintf("Returned to editor %d", iteration)
|
||||
fmt.Fprintln(editor, "edit "+text)
|
||||
wait(text)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy
|
||||
if C.cthulhu_may_be_background_notification(event.source) != 0 {
|
||||
return false
|
||||
}
|
||||
if (foreground.Client.Window == 0 || adapter.foregroundWindow.Valid() && origin.BusName != adapter.foregroundWindow.BusName) &&
|
||||
if foreground.Client.Window == 0 &&
|
||||
adapter.applicationProcess(event.source) != "mate-panel" {
|
||||
return true
|
||||
}
|
||||
@@ -43,6 +43,14 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy
|
||||
return false
|
||||
}
|
||||
defer C.g_object_unref(C.gpointer(window))
|
||||
// An embedded GTK applet has its own provider bus but belongs to the
|
||||
// socket host's window. Resolve that ancestry before the cross-bus fast
|
||||
// rejection; the host still has to pass normal X11 foreground checks.
|
||||
windowID := nativeObjectID(window)
|
||||
if adapter.foregroundWindow.Valid() && origin.BusName != adapter.foregroundWindow.BusName &&
|
||||
windowID.BusName != adapter.foregroundWindow.BusName && adapter.applicationProcess(event.source) != "mate-panel" {
|
||||
return true
|
||||
}
|
||||
focusedMenuItem := false
|
||||
if eventType == FocusEvent || eventType == FocusedStateEvent && event.detail1 != 0 {
|
||||
switch C.atspi_accessible_get_role(event.source, nil) {
|
||||
@@ -50,7 +58,7 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy
|
||||
focusedMenuItem = true
|
||||
}
|
||||
}
|
||||
if adapter.matchForeground(window, nativeObjectID(window), nil, focusedMenuItem) {
|
||||
if adapter.matchForeground(window, windowID, nil, focusedMenuItem) {
|
||||
return false
|
||||
}
|
||||
if origin.BusName == adapter.foregroundWindow.BusName {
|
||||
|
||||
@@ -609,37 +609,68 @@ gboolean cthulhu_may_be_background_notification(AtspiAccessible *source) {
|
||||
|
||||
// Return the containing top-level window. An embedded web dialog is part of
|
||||
// its frame; a standalone dialog directly below the application owns itself.
|
||||
// GTK plugs also publish a frame, but its parent chain crosses the socket's
|
||||
// provider before reaching the host window. Keep that host as the owner while
|
||||
// retaining the source object's provider identity. Same-provider nested frames
|
||||
// retain their existing nearest-window behavior.
|
||||
AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source) {
|
||||
if (source == NULL) return NULL;
|
||||
AtspiAccessible *current = g_object_ref(source);
|
||||
AtspiAccessible *window = NULL;
|
||||
for (gint depth = 0; current != NULL && depth < 256; depth++) {
|
||||
GError *error = NULL;
|
||||
AtspiRole role = atspi_accessible_get_role(current, &error);
|
||||
if (error != NULL) {
|
||||
g_clear_error(&error);
|
||||
g_object_unref(current);
|
||||
if (window != NULL) g_object_unref(window);
|
||||
return NULL;
|
||||
}
|
||||
if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) {
|
||||
return current;
|
||||
if (role == ATSPI_ROLE_APPLICATION) {
|
||||
g_object_unref(current);
|
||||
return window;
|
||||
}
|
||||
if ((role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) &&
|
||||
(window == NULL || g_strcmp0(cthulhu_accessible_bus_name(window),
|
||||
cthulhu_accessible_bus_name(current)) != 0)) {
|
||||
if (window != NULL) g_object_unref(window);
|
||||
window = g_object_ref(current);
|
||||
}
|
||||
AtspiAccessible *parent = atspi_accessible_get_parent(current, &error);
|
||||
// Only a frame with parents below the application can be a plug.
|
||||
// Refresh that chain to reject detached sockets, without invalidating
|
||||
// every ordinary top-level window for every accessibility event.
|
||||
if (window != NULL && parent != NULL && error == NULL &&
|
||||
atspi_accessible_get_role(parent, NULL) != ATSPI_ROLE_APPLICATION) {
|
||||
g_object_unref(parent);
|
||||
atspi_accessible_clear_cache_single(current);
|
||||
parent = atspi_accessible_get_parent(current, &error);
|
||||
}
|
||||
// Qt can expose a top-level QWidget as a filler rather than a frame.
|
||||
// Python's Qt utilities use the application-child boundary here.
|
||||
if (parent != NULL && error == NULL && role != ATSPI_ROLE_APPLICATION &&
|
||||
atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_APPLICATION) {
|
||||
g_object_unref(parent);
|
||||
return current;
|
||||
if (window == NULL || g_strcmp0(cthulhu_accessible_bus_name(window),
|
||||
cthulhu_accessible_bus_name(current)) != 0) {
|
||||
if (window != NULL) g_object_unref(window);
|
||||
return current;
|
||||
}
|
||||
g_object_unref(current);
|
||||
return window;
|
||||
}
|
||||
g_object_unref(current);
|
||||
if (error != NULL) {
|
||||
g_clear_error(&error);
|
||||
if (parent != NULL) g_object_unref(parent);
|
||||
if (window != NULL) g_object_unref(window);
|
||||
return NULL;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
if (current != NULL) g_object_unref(current);
|
||||
if (current == NULL) return window;
|
||||
if (window != NULL) g_object_unref(window);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package atspi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNativeEmbeddedPanelApplet(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_PANEL_APPLET") != "1" {
|
||||
t.Skip("requires private Xorg/AT-SPI session")
|
||||
}
|
||||
// Embedding belongs to the window ancestry contract, not a process-name
|
||||
// exception. Both MATE and an ordinary GTK host must retain ownership.
|
||||
for _, hostName := range []string{"panel", "other-host"} {
|
||||
t.Run(hostName, func(t *testing.T) { check_native_embedded_applet(t, hostName) })
|
||||
}
|
||||
}
|
||||
|
||||
func check_native_embedded_applet(t *testing.T, hostName string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
adapter := New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
start := func(args ...string) (io.WriteCloser, string, string) {
|
||||
t.Helper()
|
||||
command := exec.CommandContext(ctx, "/usr/bin/python3", append([]string{"../../tests/gtk_panel_applet.py"}, args...)...)
|
||||
input, err := command.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command.Stderr = os.Stderr
|
||||
if err := command.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { input.Close(); command.Process.Kill(); command.Wait() })
|
||||
line, err := bufio.NewReader(output).ReadString('\n')
|
||||
fields := strings.Fields(line)
|
||||
if err != nil || len(fields) != 3 || fields[0] != "ready" {
|
||||
t.Fatalf("fixture readiness: %q, %v", line, err)
|
||||
}
|
||||
return input, fields[1], fields[2]
|
||||
}
|
||||
host, xid, socketID := start(hostName)
|
||||
applet, _, _ := start("applet", socketID)
|
||||
editor, editorXID, _ := start("editor")
|
||||
wait := func(predicate func(Event) bool) Event {
|
||||
t.Helper()
|
||||
var last Event
|
||||
for {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
last = event
|
||||
if predicate(event) {
|
||||
return event
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("native event missing; filtered=%d last=%s %q window=%+v mismatch=%v", adapter.backgroundEventsFiltered.Load(), last.Type, last.Source.Name, last.Window, last.ForegroundMismatch)
|
||||
}
|
||||
}
|
||||
}
|
||||
key := func(args ...string) {
|
||||
t.Helper()
|
||||
if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("xdotool %v: %s, %v", args, output, err)
|
||||
}
|
||||
}
|
||||
key("windowfocus", "--sync", xid)
|
||||
fmt.Fprintln(host, "first")
|
||||
owned := wait(func(event Event) bool {
|
||||
return event.Source.Name == "Before applet" && event.Source.Focused && !event.ForegroundMismatch
|
||||
})
|
||||
key("key", "Tab")
|
||||
icon := wait(func(event Event) bool { return event.Source.Role == "icon" && event.Source.Focused })
|
||||
if icon.ForegroundMismatch || icon.Window != owned.Window || icon.Source.BusName == owned.Source.BusName || icon.Foreground != owned.Foreground {
|
||||
t.Fatalf("embedded icon lost host ownership: host=%+v icon=%+v source=%+v mismatch=%v foreground=%+v want=%+v", owned.Window, icon.Window, icon.Source.ID(), icon.ForegroundMismatch, icon.Foreground, owned.Foreground)
|
||||
}
|
||||
if icon.Source.Name != "Network applet" || icon.Source.ApplicationProcess != "applet" {
|
||||
t.Fatalf("embedded icon lost its label or provider identity: %+v", icon)
|
||||
}
|
||||
key("key", "Tab")
|
||||
wait(func(event Event) bool {
|
||||
return event.Source.Name == "Applet settings" && event.Source.Focused && event.Window == owned.Window && !event.ForegroundMismatch
|
||||
})
|
||||
key("key", "shift+Tab")
|
||||
wait(func(event Event) bool {
|
||||
return event.Source.Role == "icon" && event.Source.Focused && !event.ForegroundMismatch
|
||||
})
|
||||
key("key", "Tab")
|
||||
wait(func(event Event) bool {
|
||||
return event.Source.Name == "Applet settings" && event.Source.Focused && !event.ForegroundMismatch
|
||||
})
|
||||
key("key", "Tab")
|
||||
wait(func(event Event) bool {
|
||||
return event.Source.Name == "After applet" && event.Source.Focused && !event.ForegroundMismatch
|
||||
})
|
||||
for _, name := range []string{"Applet settings", "Network applet", "Before applet"} {
|
||||
key("key", "shift+Tab")
|
||||
wait(func(event Event) bool {
|
||||
return event.Source.Name == name && event.Source.Focused && !event.ForegroundMismatch
|
||||
})
|
||||
}
|
||||
key("windowfocus", "--sync", editorXID)
|
||||
fmt.Fprintln(editor, "edit Foreground restored")
|
||||
wait(func(event Event) bool { return event.Payload == "Foreground restored" && !event.ForegroundMismatch })
|
||||
fmt.Fprintln(applet, "stale")
|
||||
wait(func(event Event) bool {
|
||||
if event.Source.Role == "icon" && !event.ForegroundMismatch {
|
||||
t.Fatalf("background applet stole foreground: %+v", event)
|
||||
}
|
||||
return event.Payload == "Applet barrier"
|
||||
})
|
||||
// A separately published applet window cannot borrow the host's identity.
|
||||
standalone, standaloneXID, _ := start("standalone")
|
||||
key("windowfocus", "--sync", standaloneXID)
|
||||
fmt.Fprintln(standalone, "first")
|
||||
wait(func(event Event) bool {
|
||||
return event.Source.Role == "icon" && event.Source.Focused && !event.ForegroundMismatch
|
||||
})
|
||||
key("windowfocus", "--sync", xid)
|
||||
fmt.Fprintln(host, "first")
|
||||
wait(func(event Event) bool {
|
||||
return event.Source.Name == "Before applet" && event.Source.Focused && !event.ForegroundMismatch
|
||||
})
|
||||
fmt.Fprintln(standalone, "stale")
|
||||
wait(func(event Event) bool {
|
||||
if event.Source.Role == "icon" && !event.ForegroundMismatch {
|
||||
t.Fatalf("unembedded applet borrowed host ownership: %+v", event)
|
||||
}
|
||||
return event.Payload == "Applet barrier"
|
||||
})
|
||||
fmt.Fprintln(host, "detach")
|
||||
wait(func(event Event) bool { return event.Payload == "Detached host barrier" })
|
||||
fmt.Fprintln(applet, "stale")
|
||||
wait(func(event Event) bool {
|
||||
if event.Source.Role == "icon" && !event.ForegroundMismatch {
|
||||
t.Fatalf("detached applet retained host ownership: %+v", event)
|
||||
}
|
||||
return event.Payload == "Applet barrier"
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user