Fix for repeating in some instances, e.g. pidgin.

This commit is contained in:
Storm Dragon committed 2026-10-27 19:40:17 -04:00
1 parent 4d29f2bb23
commit b232c1eebb
20 files changed
+921 -7

No files matched your search

+18 -1
View File
@@ -462,7 +462,24 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
}
snapshot.description = atspi_accessible_get_description(source, &error);
if (error != NULL) {
g_error_free(error);
g_clear_error(&error);
}
// Orca uses the containing panel's label for otherwise unlabeled icons.
// Keep an icon's own name/description and never borrow from other roles.
if (!snapshot.defunct && g_strcmp0(snapshot.role, "icon") == 0 &&
(snapshot.name == NULL || *snapshot.name == '\0') &&
(snapshot.description == NULL || *snapshot.description == '\0')) {
AtspiAccessible *parent = atspi_accessible_get_parent(source, &error);
if (error == NULL && parent != NULL && atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_PANEL) {
g_free(snapshot.name);
snapshot.name = atspi_accessible_get_name(parent, NULL);
if (snapshot.name == NULL || *snapshot.name == '\0') {
g_free(snapshot.name);
snapshot.name = atspi_accessible_get_description(parent, NULL);
}
}
if (parent != NULL) g_object_unref(parent);
g_clear_error(&error);
}
if (captureDocumentMetadata) {
cthulhu_capture_document_metrics(source, &snapshot);
@@ -27,7 +27,8 @@ 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.foregroundWindow.Valid() && origin.BusName != adapter.foregroundWindow.BusName) &&
adapter.applicationProcess(event.source) != "mate-panel" {
return true
}
window := C.cthulhu_window_ancestor(event.source)
+3 -1
View File
@@ -504,8 +504,10 @@ gboolean cthulhu_active_menu_window(AtspiAccessible *window) {
AtspiStateSet *states = atspi_accessible_get_state_set(window);
gboolean active = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_ACTIVE);
gboolean showing = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_SHOWING);
gboolean unavailable = states == NULL || atspi_state_set_contains(states, ATSPI_STATE_ICONIFIED) ||
atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
if (states != NULL) g_object_unref(states);
if (!active || !showing || atspi_accessible_get_child_count(window, NULL) != 1) return FALSE;
if (!active || !showing || unavailable || atspi_accessible_get_child_count(window, NULL) != 1) return FALSE;
AtspiAccessible *child = atspi_accessible_get_child_at_index(window, 0, NULL);
if (child == NULL) return FALSE;
AtspiRole childRole = atspi_accessible_get_role(child, NULL);
+24 -1
View File
@@ -71,7 +71,7 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID,
if !state.Known {
return true
}
if state.Window == 0 || window == nil {
if window == nil {
if check != nil {
check.Reason = "no X11 foreground window or accessible window"
}
@@ -82,6 +82,18 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID,
check.AccessiblePID = pid
}
pid = adapter.foregroundProcessID(pid)
// Desktop panel menus can grab the keyboard while X11 still names the
// previous application. Orca accepts their active, showing menu window.
// Keep the correlated X11 owner so dismissal can return to that client.
if adapter.is_panel_menu(window) {
return true
}
if state.Window == 0 {
if check != nil {
check.Reason = "no X11 foreground window or accessible window"
}
return false
}
if adapter.foregroundWindow.Valid() {
if adapter.foregroundWindow == id {
return true
@@ -165,6 +177,17 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID,
return true
}
// The exception is for a native menu from the panel process, never an
// arbitrary panel control or a provider claiming the panel's accessible name.
// Query fresh window state on the GLib owner; no popup state is retained.
func (adapter *Adapter) is_panel_menu(window *C.AtspiAccessible) bool {
if adapter.applicationProcess(window) != "mate-panel" {
return false
}
C.atspi_accessible_clear_cache_single(window)
return C.cthulhu_active_menu_window(window) != 0
}
func (adapter *Adapter) replacesForegroundWindow(window *C.AtspiAccessible, id ObjectID, foregroundPID, pid int) bool {
if foregroundPID <= 0 || pid != foregroundPID || id.BusName != adapter.foregroundWindow.BusName {
return false
+188
View File
@@ -0,0 +1,188 @@
// 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 TestNativeMatePanelMenuOutsideForeground(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_PANEL_MENU") != "1" {
t.Skip("requires private Xorg/AT-SPI session")
}
for _, test := range []struct {
name, processName string
empty bool
}{
{"over editor", "mate-panel", false},
{"empty desktop", "mate-panel", true},
{"impostor", "impostor", false},
} {
t.Run(test.name, func(t *testing.T) { check_native_panel_menu(t, test.processName, test.empty) })
}
}
func check_native_panel_menu(t *testing.T, processName string, empty bool) {
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(name string) (io.WriteCloser, string) {
t.Helper()
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_panel_menu.py", name)
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) != 2 || fields[0] != "ready" {
t.Fatalf("fixture readiness: %q, %v", line, err)
}
return input, fields[1]
}
panel, _ := start(processName)
editor, xid := start("editor")
tree, err := adapter.CaptureDesktop(ctx)
if err != nil {
t.Fatal(err)
}
check_native_panel_icons(t, tree)
key := func(args ...string) {
t.Helper()
if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
t.Fatalf("xdotool: %s, %v", output, err)
}
}
key("windowfocus", "--sync", xid)
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: %v; filtered=%d last=%+v", ctx.Err(), adapter.backgroundEventsFiltered.Load(), last)
}
}
}
fmt.Fprintln(editor, "edit Foreground ready")
owned := wait(func(event Event) bool { return event.Payload == "Foreground ready" && !event.ForegroundMismatch })
if empty {
key("windowfocus", "0")
for adapter.Foreground().Client.Window != 0 {
select {
case <-adapter.ForegroundChanges():
case <-ctx.Done():
t.Fatal("empty desktop foreground missing")
}
}
owned.Foreground = adapter.Foreground()
}
fmt.Fprintln(panel, "popup")
activation := wait(func(event Event) bool {
return event.Type == WindowActivatedEvent && event.Source.ApplicationName == "mate-panel"
})
if processName != "mate-panel" {
if !activation.ForegroundMismatch {
t.Fatalf("accessible name enabled panel exception for another process: %+v", activation)
}
key("key", "Escape")
return
}
if activation.ForegroundMismatch || activation.Foreground != owned.Foreground {
t.Fatalf("panel popup rejected with unchanged X11 foreground: %+v", activation)
}
for _, step := range []struct{ key, name string }{
{"Down", "First application"}, {"Down", "Utilities"}, {"Right", "Example utility"},
{"Left", "Utilities"}, {"Down", "Last application"}, {"Up", "Utilities"},
} {
key("key", step.key)
event := wait(func(event Event) bool {
return event.Source.Name == step.name && (event.Type == FocusEvent || event.Type == FocusedStateEvent || event.Type == SelectionChangedEvent)
})
if event.ForegroundMismatch || event.Foreground != owned.Foreground || event.Source.ApplicationProcess != "mate-panel" {
t.Fatalf("panel navigation lost ownership: %+v", event)
}
}
key("key", "Escape")
fmt.Fprintln(panel, "ordinary")
ordinary := wait(func(event Event) bool {
return event.Type == WindowActivatedEvent && event.Source.ApplicationName == "mate-panel"
})
if !ordinary.ForegroundMismatch {
t.Fatalf("ordinary panel window borrowed menu exception: %+v", ordinary)
}
fmt.Fprintln(panel, "stale")
wait(func(event Event) bool {
if event.Source.Name == "First application" && (event.Type == FocusEvent || event.Type == FocusedStateEvent) {
t.Fatalf("hidden menu item escaped filtering: %+v", event)
}
return event.Payload == "Stale barrier"
})
if empty {
key("windowfocus", "--sync", xid)
}
fmt.Fprintln(editor, "edit Foreground restored")
restored := wait(func(event Event) bool { return event.Payload == "Foreground restored" && !event.ForegroundMismatch })
if restored.Window != owned.Window || !empty && restored.Foreground != owned.Foreground {
t.Fatalf("panel dismissal changed editor ownership: %+v", restored)
}
}
func check_native_panel_icons(t *testing.T, tree *AccessibleTree) {
t.Helper()
tree.mu.RLock()
defer tree.mu.RUnlock()
icons := 0
for _, node := range tree.nodes {
icon := node.snapshot
if icon.Role != "icon" {
continue
}
icons++
parent := tree.nodes[node.parent].snapshot
want := ""
switch parent.Name {
case "Network applet":
want = parent.Name
case "Unused applet name":
if icon.Description == "" {
want = "Named icon"
}
}
if icon.Name != want {
t.Fatalf("panel icon name=%q description=%q parent=%q; want %q", icon.Name, icon.Description, parent.Name, want)
}
}
if icons != 8 {
t.Fatalf("captured %d panel icons; want four cases from each provider", icons)
}
}