Attempt to fix problems with flatpacks, e.g. Steam.
This commit is contained in:
1 parent
1c4e0a99bc
commit
53d14d561b
20 files changed
+510
-20
No files matched your search
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user