Attempt to fix problems with flatpacks, e.g. Steam.

This commit is contained in:
ak47 committed 2026-10-17 20:58:35 -04:00
1 parent 1c4e0a99bc
commit 53d14d561b
20 files changed
+510 -20

No files matched your search

+1 -1
View File
@@ -132,7 +132,7 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
"bus", event.Window.BusName, "window_path", event.Window.Path,
"x_window", client.Window, "epoch", event.Foreground.Epoch,
"current_epoch", state.foregroundState.Epoch, "reason", check.Reason,
"x_pid", client.PID, "accessible_pid", check.AccessiblePID,
"x_pid", client.PID, "x_property_pid", client.PropertyPID, "accessible_pid", check.AccessiblePID,
"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},
+2 -1
View File
@@ -195,7 +195,8 @@ func (state *eventLoopState) syncForeground(foreground foregroundMonitor, logger
state.owner.clear()
clearOwnedFocus()
logger.Debug("X11 foreground ownership changed", "x_window", latest.Client.Window,
"pid", latest.Client.PID, "known", latest.Client.Known, "epoch", latest.Epoch)
"pid", latest.Client.PID, "property_pid", latest.Client.PropertyPID,
"known", latest.Client.Known, "epoch", latest.Epoch)
}
state.foregroundState = latest
}
+1
View File
@@ -114,6 +114,7 @@ type Adapter struct {
applicationProcesses map[string]string // GLib owner; bounded provider identity cache
webNotificationRoots map[ObjectID]bool // copied identities only; no native references
foregroundReader *xorg.ForegroundReader // GLib thread only
foregroundProcesses map[int]bool // GLib thread; provider PID matches for current client
foreground atomic.Pointer[ForegroundState]
echoFocus atomic.Pointer[echoFocus]
eventListener *C.AtspiEventListener // GLib owner; released with subscriptions.
+5 -1
View File
@@ -29,6 +29,9 @@ func (adapter *Adapter) refreshForeground() {
return
}
state := ForegroundState{Client: value, Epoch: previous.Epoch}
if value.Window != previous.Client.Window || value.PID != previous.Client.PID || value.Known != previous.Client.Known {
clear(adapter.foregroundProcesses)
}
if value.Known != previous.Client.Known || value.Window != previous.Client.Window {
state.Epoch++
adapter.foregroundWindow = ObjectID{}
@@ -78,6 +81,7 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID,
if check != nil {
check.AccessiblePID = pid
}
pid = adapter.foregroundProcessID(pid)
if adapter.foregroundWindow.Valid() {
if adapter.foregroundWindow == id {
return true
@@ -265,7 +269,7 @@ func (adapter *Adapter) recoverForeground() {
continue
}
pid := int(C.atspi_accessible_get_process_id(application, nil))
if pid != state.Client.PID {
if adapter.foregroundProcessID(pid) != state.Client.PID {
C.g_object_unref(C.gpointer(application))
continue
}
+1 -1
View File
@@ -97,7 +97,7 @@ func (adapter *Adapter) nativeOCRGeometry(ctx context.Context, object ObjectID,
return OCRGeometry{}, ErrOCRGeometry
}
defer C.g_object_unref(C.gpointer(window))
if int(C.atspi_accessible_get_process_id(window, nil)) != before.Client.PID || (adapter.foregroundWindow.Valid() && adapter.foregroundWindow != nativeObjectID(window)) {
if adapter.foregroundProcessID(int(C.atspi_accessible_get_process_id(window, nil))) != before.Client.PID || (adapter.foregroundWindow.Valid() && adapter.foregroundWindow != nativeObjectID(window)) {
return OCRGeometry{}, ErrOCRGeometry
}
states := C.atspi_accessible_get_state_set(window)
+100
View File
@@ -0,0 +1,100 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"fmt"
"io"
"os"
"strings"
"syscall"
)
type flatpakInstance struct {
application, instance string
}
// Flatpak gives its D-Bus proxy and application the same /.flatpak-info.
// Compare a running instance, never just an application ID: two instances of
// the same application must not acquire one another's foreground window.
func readFlatpakInstance(procRoot string, pid int) flatpakInstance {
if pid <= 0 {
return flatpakInstance{}
}
file, err := os.OpenFile(fmt.Sprintf("%s/%d/root/.flatpak-info", procRoot, pid), os.O_RDONLY|syscall.O_NONBLOCK, 0)
if err != nil {
return flatpakInstance{}
}
defer file.Close()
const limit = 64 * 1024
stat, err := file.Stat()
if err != nil || !stat.Mode().IsRegular() || stat.Size() > limit {
return flatpakInstance{}
}
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil || len(data) > limit {
return flatpakInstance{}
}
var result flatpakInstance
var section string
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section = line
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
return flatpakInstance{}
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
if section == "[Application]" && key == "name" {
if result.application != "" {
return flatpakInstance{}
}
result.application = value
}
if section == "[Instance]" && key == "instance-id" {
if result.instance != "" {
return flatpakInstance{}
}
result.instance = value
}
}
if result.application == "" || result.instance == "" {
return flatpakInstance{}
}
return result
}
func sameFlatpakInstance(procRoot string, first, second int) bool {
identity := readFlatpakInstance(procRoot, first)
return identity != (flatpakInstance{}) && identity == readFlatpakInstance(procRoot, second)
}
// Owned by the GLib thread and cleared with the foreground client. This only
// resolves process identity; fresh window state and geometry still decide
// whether an accessible can own focus.
func (adapter *Adapter) foregroundProcessID(pid int) int {
client := adapter.Foreground().Client
if client.Window == 0 || client.PID <= 0 || pid <= 0 || pid == client.PID {
return pid
}
matched, known := adapter.foregroundProcesses[pid]
if !known {
matched = sameFlatpakInstance("/proc", client.PID, pid)
if adapter.foregroundProcesses == nil || len(adapter.foregroundProcesses) >= 128 {
adapter.foregroundProcesses = make(map[int]bool)
}
adapter.foregroundProcesses[pid] = matched
}
if matched {
return client.PID
}
return pid
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestFlatpakProcessInstanceBoundaries(t *testing.T) {
root := t.TempDir()
write := func(pid int, data string) {
t.Helper()
dir := filepath.Join(root, fmt.Sprint(pid), "root")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".flatpak-info"), []byte(data), 0600); err != nil {
t.Fatal(err)
}
}
metadata := "[Application]\nname=org.example.App\n[Instance]\ninstance-id=123\n"
write(100, metadata)
for _, test := range []struct {
name, data string
want bool
}{
{"same instance", metadata, true},
{"other instance of same app", strings.ReplaceAll(metadata, "123", "456"), false},
{"other app", strings.ReplaceAll(metadata, "org.example.App", "org.example.Other"), false},
{"app ID alone", "[Application]\nname=org.example.App\n", false},
{"instance ID alone", "[Instance]\ninstance-id=123\n", false},
{"wrong section", "[Application]\nname=org.example.App\ninstance-id=123\n", false},
{"empty metadata", "", false},
{"malformed metadata", metadata + "broken", false},
{"duplicate identity", metadata + "instance-id=456\n", false},
{"oversized metadata", metadata + strings.Repeat("#", 65536), false},
} {
t.Run(test.name, func(t *testing.T) {
write(200, test.data)
if got := sameFlatpakInstance(root, 100, 200); got != test.want {
t.Fatalf("same instance = %v; want %v", got, test.want)
}
})
}
if sameFlatpakInstance(root, 100, 300) || sameFlatpakInstance(root, 300, 400) || sameFlatpakInstance(root, 0, 0) {
t.Fatal("missing process or unknown identity matched")
}
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"context"
"os"
"os/exec"
"strconv"
"testing"
"time"
)
// The GTK provider publishes its host PID over AT-SPI, but its X11 property
// deliberately contains a different PID, as with a Flatpak PID namespace.
func TestNativeForegroundProcessIdentity(t *testing.T) {
if os.Getenv("CTHULHU_FOREGROUND_PID") != "1" {
t.Skip("requires isolated GTK/X11 process identity fixture")
}
pid, err := strconv.Atoi(os.Getenv("CTHULHU_TREE_PID"))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
adapter := New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
if _, err := adapter.CaptureDesktop(ctx); err != nil {
t.Fatal(err)
}
if foreground := adapter.Foreground(); foreground.Client.PID != pid {
t.Fatalf("foreground PID = %d; want host provider PID %d (window property is 252)", foreground.Client.PID, pid)
}
if err := exec.CommandContext(ctx, "xdotool", "key", "F6").Run(); err != nil {
t.Fatal(err)
}
for {
select {
case event := <-adapter.Events():
if event.Type != FocusedStateEvent || event.Detail1 == 0 || event.Source.Name != "Caret review probe" {
continue
}
wantMismatch := os.Getenv("CTHULHU_FOREGROUND_PROXY_OTHER") == "1"
if event.ForegroundMismatch != wantMismatch || event.Foreground.Client.PID != pid || !event.Window.Valid() {
t.Fatalf("foreground mismatch = %v; want %v: %+v", event.ForegroundMismatch, wantMismatch, event)
}
if os.Getenv("CTHULHU_FOREGROUND_PROXY") == "1" && event.Source.ApplicationProcess != "xdg-dbus-proxy" {
t.Fatalf("fixture did not use a separate D-Bus proxy: %q", event.Source.ApplicationProcess)
}
return
case <-ctx.Done():
t.Fatal("foreground control focus event missing")
}
}
}
+29 -6
View File
@@ -1,6 +1,7 @@
package xorg
/*
#cgo pkg-config: xres
#include "commands_native.h"
#include <X11/XKBlib.h>
*/
@@ -13,30 +14,52 @@ func ActiveWindowIsXTerm() bool {
// Foreground is an immutable X11 client identity and its screen rectangle.
// Known with Window == 0 means no foreground client, not missing metadata.
type Foreground struct {
Known bool
Window uint64
PID int
Known bool
Window uint64
PID int
// PropertyPID retains _NET_WM_PID for diagnostics. PID uses the X server's
// local client identity when available, so Flatpak namespaces agree with AT-SPI.
PropertyPID int
X, Y, Width, Height int
OuterX, OuterY, OuterWidth, OuterHeight int
}
// ForegroundReader has no goroutine. Its native event-loop owner opens, reads,
// and closes it on that thread; no consumer performs X11 I/O.
type ForegroundReader struct{ display *C.Display }
type ForegroundReader struct {
display *C.Display
clientPIDs bool
pidWindow uint64
propertyPID, clientPID int
}
func OpenForegroundReader() *ForegroundReader {
display := C.XOpenDisplay(nil)
if display == nil {
return nil
}
return &ForegroundReader{display: display}
return &ForegroundReader{display: display, clientPIDs: C.cthulhu_xres_client_pids_supported(display) != 0}
}
func (reader *ForegroundReader) Read() Foreground {
if reader == nil {
return Foreground{}
}
value := C.read_foreground(reader.display)
return Foreground{Known: value.known != 0, Window: uint64(value.window), PID: int(value.pid),
window, propertyPID := uint64(value.window), int(value.pid)
// The connection's owner also owns this one-client cache. Window departure
// (including no foreground) or a changed property discards the old identity.
// Resizing and ordinary input never trigger an extra server query.
if window != reader.pidWindow || propertyPID != reader.propertyPID {
reader.pidWindow, reader.propertyPID, reader.clientPID = window, propertyPID, 0
if window != 0 && reader.clientPIDs {
reader.clientPID = int(C.cthulhu_xres_client_pid(reader.display, C.Window(window)))
}
}
pid := propertyPID
if reader.clientPID > 0 {
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)}
}
func (reader *ForegroundReader) Close() {
+2
View File
@@ -36,6 +36,8 @@ typedef struct { int xterm; SteamFocusSample steam; } CommandFocusSample;
int cthulhu_active_window_is_xterm(void);
int cthulhu_display_active_window_xterm_match(Display *display);
Foreground read_foreground(Display *display);
int cthulhu_xres_client_pids_supported(Display *display);
int cthulhu_xres_client_pid(Display *display, Window window);
PointerPosition cthulhu_read_pointer(Display *display);
// Caller holds the X11 read guard; optional Steam metadata shares the same
// active-window and WM_CLASS reads as the XTerm match.
+40
View File
@@ -0,0 +1,40 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
#include "commands_native.h"
#include <sys/types.h>
#include <X11/extensions/XRes.h>
int cthulhu_xres_client_pids_supported(Display *display) {
int event, error, major = 0, minor = 0;
cthulhu_x11_begin_read(display);
int supported = XResQueryExtension(display, &event, &error) &&
XResQueryVersion(display, &major, &minor) &&
(major > 1 || (major == 1 && minor >= 2));
if (cthulhu_x11_end_read(display)) return 0;
return supported;
}
// _NET_WM_PID is written by the application in its own PID namespace. XRes
// identifies the local connection from the server side instead. Unsupported
// servers, remote clients and disappeared clients retain the property fallback.
int cthulhu_xres_client_pid(Display *display, Window window) {
XResClientIdSpec spec = {.client = window, .mask = XRES_CLIENT_ID_PID_MASK};
XResClientIdValue *ids = NULL;
long count = 0;
int pid = 0;
cthulhu_x11_begin_read(display);
if (XResQueryClientIds(display, 1, &spec, &count, &ids) == Success) {
for (long i = 0; i < count; i++) {
pid_t candidate = XResGetClientPid(&ids[i]);
if (candidate > 0) {
pid = candidate;
break;
}
}
}
if (ids) XResClientIdsDestroy(count, ids);
if (cthulhu_x11_end_read(display)) return 0;
return pid;
}
@@ -60,6 +60,35 @@ func TestNativeWindowTitleDiscovery(t *testing.T) {
t.Fatal("cannot open X11")
}
defer reader.Close()
// Flatpak's _NET_WM_PID is in a different namespace from the AT-SPI
// provider. XRes must identify the actual local connection owner instead.
xres := os.Getenv("CTHULHU_XRES_DISABLED") != "1"
if reader.clientPIDs != xres {
t.Fatalf("XRes client PID support = %v; want %v", reader.clientPIDs, xres)
}
checkPID := func(property int) {
t.Helper()
want := property
if xres {
want = fixture.Process.Pid
}
foreground := reader.Read()
if foreground.PID != want || foreground.PropertyPID != property {
t.Fatalf("foreground = %+v; want PID %d and property %d", foreground, want, property)
}
}
checkPID(0)
command("pid", "normal", "252")
checkPID(252)
checkPID(252) // cached reads must retain the host identity
command("pid", "normal", "253")
checkPID(253)
command("empty", "normal", "")
if foreground := reader.Read(); foreground.Window != 0 || foreground.PID != 0 || foreground.PropertyPID != 0 {
t.Fatalf("empty workspace retained a client: %+v", foreground)
}
command("activate", "normal", "")
checkPID(253)
applications := reader.ApplicationWindows()
var foundExample, foundGame bool
for _, application := range applications {
@@ -89,6 +118,7 @@ func TestNativeWindowTitleDiscovery(t *testing.T) {
command("legacy", "normal", "Legacy title")
expect("normal", "normal", "Legacy title", "example")
command("activate", "desktop", "")
checkPID(0)
command("focus", "control", "")
expect("desktop", "game", "Wine game", "game.exe")
command("title", "game", "Wine game loading")
@@ -100,9 +130,13 @@ func TestNativeWindowTitleDiscovery(t *testing.T) {
command("hide", "game", "")
expect("desktop", "second", "Wine dialog", "game.exe")
command("destroy", "desktop", "")
if foreground := reader.Read(); foreground.Window != 0 || foreground.PID != 0 {
t.Fatalf("destroyed window retained a client: %+v", foreground)
}
if value := reader.ReadTitle(windows["desktop"]); value != (WindowTitle{}) {
t.Fatalf("destroyed desktop = %+v", value)
}
command("activate", "normal", "")
checkPID(253)
expect("normal", "normal", "Legacy title", "example")
}