Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+326
View File
@@ -0,0 +1,326 @@
// Package wine supervises the optional installed Cthulhu Wine accessibility
// helper. Native Wine objects stay in that process; Go receives D-Bus messages.
package wine
import (
"context"
"errors"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
const managedKey = "CTHULHU_WINE_ACCESS_MANAGED"
type prefix struct {
path, loader string
environment map[string]string
}
type process struct {
cmd *exec.Cmd
done chan struct{}
started time.Time
seen time.Time
failures int
next time.Time
}
type Manager struct {
logger *slog.Logger
helper string
procRoot string
cancel context.CancelFunc
done chan struct{}
}
func New(logger *slog.Logger) *Manager {
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
return &Manager{logger: logger, helper: helperPath(), procRoot: "/proc"}
}
func helperPath() string {
if path := os.Getenv("CTHULHU_WINE_ACCESS_HELPER"); executable(path) {
return path
}
if path := filepath.Join(os.Getenv("HOME"), ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe"); executable(path) {
return path
}
if path := "/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe"; executable(path) {
return path
}
if path, err := exec.LookPath("cthulhu-wine-access.exe"); err == nil {
return path
}
return ""
}
func executable(path string) bool {
if path == "" {
return false
}
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0111 != 0
}
func (manager *Manager) Start(ctx context.Context) error {
if manager.helper == "" {
manager.logger.Info("Wine accessibility helper unavailable")
return nil
}
if manager.cancel != nil {
return errors.New("Wine accessibility manager already started")
}
workerCtx, cancel := context.WithCancel(ctx)
manager.cancel = cancel
manager.done = make(chan struct{})
go manager.run(workerCtx)
return nil
}
func (manager *Manager) Stop(ctx context.Context) error {
if manager.cancel == nil {
return nil
}
manager.cancel()
select {
case <-manager.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (manager *Manager) run(ctx context.Context) {
defer close(manager.done)
active := make(map[string]*process)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
manager.poll(active)
for {
select {
case <-ctx.Done():
for _, child := range active {
stop(child)
}
return
case <-ticker.C:
manager.poll(active)
}
}
}
func (manager *Manager) poll(active map[string]*process) {
now := time.Now()
discovered := discover(manager.procRoot, uint32(os.Getuid()))
for path, source := range discovered {
child := active[path]
if child == nil {
child = &process{}
active[path] = child
}
child.seen = now
if child.cmd != nil {
select {
case <-child.done:
if now.Sub(child.started) < 10*time.Second {
child.failures++
} else {
child.failures = 0
}
child.next = now.Add(backoff(child.failures))
child.cmd = nil
default:
continue
}
}
if now.Before(child.next) {
continue
}
if err := manager.launch(child, source, now); err != nil {
child.failures++
child.next = now.Add(backoff(child.failures))
manager.logger.Warn("start Wine accessibility helper", "error", err)
}
}
for path, child := range active {
if _, found := discovered[path]; !found && now.Sub(child.seen) >= 10*time.Second {
stop(child)
delete(active, path)
}
}
}
func backoff(failures int) time.Duration {
if failures > 6 {
failures = 6
}
return time.Duration(1<<failures) * time.Second
}
func (manager *Manager) launch(child *process, source prefix, now time.Time) error {
cmd := exec.Command(manager.helper)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
environment := os.Environ()
for _, key := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"} {
if value := source.environment[key]; value != "" {
environment = append(environment, key+"="+value)
}
}
environment = append(environment, "WINEPREFIX="+source.path, managedKey+"=1")
if source.loader != "" {
environment = append(environment, "WINELOADER="+source.loader)
}
cmd.Env = environment
if err := cmd.Start(); err != nil {
return err
}
child.cmd, child.done, child.started = cmd, make(chan struct{}), now
go manager.wait(cmd, child)
manager.logger.Info("Wine accessibility helper started")
return nil
}
func (manager *Manager) wait(cmd *exec.Cmd, child *process) {
err := cmd.Wait()
if err != nil {
manager.logger.Warn("Wine accessibility helper exited", "error", err)
} else {
manager.logger.Info("Wine accessibility helper exited")
}
close(child.done)
}
func stop(child *process) {
if child.cmd == nil {
return
}
select {
case <-child.done:
child.cmd = nil
return
default:
}
_ = child.cmd.Process.Signal(syscall.SIGTERM)
select {
case <-child.done:
case <-time.After(2 * time.Second):
_ = child.cmd.Process.Kill()
<-child.done
}
child.cmd = nil
}
func discover(root string, uid uint32) map[string]prefix {
result := make(map[string]prefix)
entries, err := os.ReadDir(root)
if err != nil {
return result
}
for _, entry := range entries {
if _, err := strconv.Atoi(entry.Name()); err != nil {
continue
}
base := filepath.Join(root, entry.Name())
info, err := os.Stat(base)
if err != nil {
continue
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Uid != uid {
continue
}
exe, err := os.Readlink(filepath.Join(base, "exe"))
if err != nil {
continue
}
name := strings.ToLower(filepath.Base(exe))
if name != "wine" && name != "wine64" && name != "wine-preloader" && name != "wine64-preloader" {
continue
}
commandLine, err := os.ReadFile(filepath.Join(base, "cmdline"))
if err != nil || !applicationProcess(string(commandLine)) {
continue
}
raw, err := os.ReadFile(filepath.Join(base, "environ"))
if err != nil {
continue
}
environment := make(map[string]string)
for _, item := range strings.Split(string(raw), "\x00") {
key, value, found := strings.Cut(item, "=")
if found {
environment[key] = value
}
}
if environment[managedKey] == "1" {
continue
}
path := environment["WINEPREFIX"]
if path == "" && environment["STEAM_COMPAT_DATA_PATH"] != "" {
path = filepath.Join(environment["STEAM_COMPAT_DATA_PATH"], "pfx")
}
// Wine defaults to .wine in the launching process's home directory.
// Use that process's HOME, which may differ from the reader's HOME.
if path == "" && filepath.IsAbs(environment["HOME"]) {
path = filepath.Join(environment["HOME"], ".wine")
}
if path == "" {
continue
}
path = filepath.Clean(path)
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
if !filepath.IsAbs(path) {
continue
}
loader := environment["WINELOADER"]
if !executable(loader) {
loader = exe
if strings.HasSuffix(name, "-preloader") {
loader = strings.TrimSuffix(exe, "-preloader")
}
if !executable(loader) {
loader = ""
}
}
result[path] = prefix{path: path, loader: loader, environment: environment}
}
return result
}
// Wine services use the same Unix loader as applications and can outlive them.
// Counting them keeps the helper alive indefinitely and can open an otherwise
// unused virtual desktop when the reader starts.
func applicationProcess(commandLine string) bool {
arguments := strings.Split(commandLine, "\x00")
program := strings.ToLower(strings.TrimSpace(arguments[0]))
if program == "" {
return false
}
program = filepath.Base(strings.ReplaceAll(program, "\\", "/"))
switch program {
case "", "services.exe", "winedevice.exe", "svchost.exe", "plugplay.exe", "rpcss.exe", "lsass.exe", "wineboot.exe", "winemenubuilder.exe":
return false
case "explorer.exe":
// Ordinary Explorer windows are applications; its desktop host is not.
for _, argument := range arguments[1:] {
argument = strings.ToLower(strings.TrimSpace(argument))
if argument == "/desktop" || strings.HasPrefix(argument, "/desktop=") {
return false
}
}
}
return true
}
+299
View File
@@ -0,0 +1,299 @@
package wine
import (
"context"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func TestDiscoverWinePrefixesAndExcludeManagedHelper(t *testing.T) {
root := t.TempDir()
loader := filepath.Join(root, "wine-preloader")
if err := os.WriteFile(loader, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
prefixPath := filepath.Join(root, "prefix")
if err := os.Mkdir(prefixPath, 0700); err != nil {
t.Fatal(err)
}
procRoot := filepath.Join(root, "proc")
if err := os.Mkdir(procRoot, 0700); err != nil {
t.Fatal(err)
}
makeProcess := func(pid int, exe, environment string) {
t.Helper()
path := filepath.Join(procRoot, strconv.Itoa(pid))
if err := os.Mkdir(path, 0700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(exe, filepath.Join(path, "exe")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "environ"), []byte(environment), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "cmdline"), []byte("C:\\app\\application.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
}
makeProcess(101, loader, "WINEPREFIX="+prefixPath+"\x00DISPLAY=:7\x00")
makeProcess(102, loader, "WINEPREFIX="+prefixPath+"\x00"+managedKey+"=1\x00")
makeProcess(103, "/bin/sleep", "WINEPREFIX="+prefixPath+"\x00")
makeProcess(104, loader, "WINEPREFIX="+filepath.Join(root, "idle-prefix")+"\x00")
if err := os.WriteFile(filepath.Join(procRoot, "104", "cmdline"), []byte("C:\\windows\\system32\\winedevice.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
got := discover(procRoot, uint32(os.Getuid()))
if len(got) != 1 {
t.Fatalf("prefixes = %+v, want one unmanaged Wine prefix", got)
}
selected := got[prefixPath]
if selected.path != prefixPath || selected.environment["DISPLAY"] != ":7" {
t.Fatalf("prefix = %+v", selected)
}
if selected.loader != "" {
t.Fatalf("missing derived wine loader should fall back to launcher default, got %q", selected.loader)
}
}
func TestDiscoverDefaultWinePrefix(t *testing.T) {
t.Setenv("HOME", t.TempDir())
for _, test := range []struct {
name, extra, want string
}{
{name: "unset prefix", want: "home/.wine"},
{name: "empty prefix", extra: "WINEPREFIX=\x00", want: "home/.wine"},
{name: "explicit prefix", extra: "WINEPREFIX={root}/custom\x00STEAM_COMPAT_DATA_PATH={root}/steam\x00", want: "custom"},
{name: "Steam prefix", extra: "STEAM_COMPAT_DATA_PATH={root}/steam\x00", want: "steam/pfx"},
{name: "managed helper", extra: managedKey + "=1\x00"},
{name: "missing home", extra: "HOME=\x00"},
{name: "relative home", extra: "HOME=relative\x00"},
} {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
loader := filepath.Join(root, "wine")
if err := os.WriteFile(loader, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
proc := filepath.Join(root, "200")
if err := os.Mkdir(proc, 0700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(loader, filepath.Join(proc, "exe")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("application.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
environment := "HOME=" + filepath.Join(root, "home") + "\x00DISPLAY=:7\x00" +
strings.ReplaceAll(test.extra, "{root}", root)
if err := os.WriteFile(filepath.Join(proc, "environ"), []byte(environment), 0600); err != nil {
t.Fatal(err)
}
got := discover(root, uint32(os.Getuid()))
if test.want == "" {
if len(got) != 0 {
t.Fatalf("prefixes = %+v, want none", got)
}
return
}
want := filepath.Join(root, test.want)
if len(got) != 1 {
t.Fatalf("prefixes = %+v, want only %q", got, want)
}
selected := got[want]
if selected.path != want || selected.loader != loader || selected.environment["DISPLAY"] != ":7" {
t.Fatalf("prefix = %+v, want %q with its loader and display", selected, want)
}
})
}
}
func TestHelperPathPrefersConfiguredThenLocalHelper(t *testing.T) {
root := t.TempDir()
t.Setenv("HOME", root)
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", "")
local := filepath.Join(root, ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe")
if err := os.MkdirAll(filepath.Dir(local), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(local, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
if got := helperPath(); got != local {
t.Fatalf("helperPath() = %q, want %q", got, local)
}
configured := filepath.Join(root, "configured-helper")
if err := os.WriteFile(configured, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", configured)
if got := helperPath(); got != configured {
t.Fatalf("helperPath() = %q, want %q", got, configured)
}
}
func TestManagerLaunchesAndStopsHelperForActivePrefix(t *testing.T) {
root := t.TempDir()
procRoot := filepath.Join(root, "proc")
if err := os.Mkdir(procRoot, 0700); err != nil {
t.Fatal(err)
}
loader := filepath.Join(root, "wine")
if err := os.WriteFile(loader, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
prefixPath := filepath.Join(root, "prefix")
if err := os.Mkdir(prefixPath, 0700); err != nil {
t.Fatal(err)
}
proc := filepath.Join(procRoot, "300")
if err := os.Mkdir(proc, 0700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(loader, filepath.Join(proc, "exe")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("application.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "environ"), []byte("WINEPREFIX="+prefixPath+"\x00WINELOADER="+loader+"\x00"), 0600); err != nil {
t.Fatal(err)
}
output := filepath.Join(root, "helper-output")
t.Setenv("CTHULHU_TEST_WINE_OUTPUT", output)
helper := filepath.Join(root, "helper")
script := "#!/bin/sh\nprintf '%s\\n' \"$WINEPREFIX\" \"$CTHULHU_WINE_ACCESS_MANAGED\" > \"$CTHULHU_TEST_WINE_OUTPUT\"\nexec sleep 30\n"
if err := os.WriteFile(helper, []byte(script), 0700); err != nil {
t.Fatal(err)
}
manager := &Manager{logger: slog.New(slog.DiscardHandler), helper: helper, procRoot: procRoot}
if err := manager.Start(context.Background()); err != nil {
t.Fatal(err)
}
defer manager.Stop(context.Background())
deadline := time.Now().Add(time.Second)
var contents []byte
for time.Now().Before(deadline) {
contents, _ = os.ReadFile(output)
if len(contents) != 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
if string(contents) != prefixPath+"\n1\n" {
t.Fatalf("helper environment = %q", contents)
}
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := manager.Stop(stopCtx); err != nil {
t.Fatal(err)
}
}
func TestDiscoverSteamCompatPrefix(t *testing.T) {
root := t.TempDir()
loader := filepath.Join(root, "wine")
if err := os.WriteFile(loader, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
proc := filepath.Join(root, "200")
if err := os.Mkdir(proc, 0700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(loader, filepath.Join(proc, "exe")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("application.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "environ"), []byte(strings.Join([]string{
"STEAM_COMPAT_DATA_PATH=" + root, "WINELOADER=" + loader, ""}, "\x00")), 0600); err != nil {
t.Fatal(err)
}
got := discover(root, uint32(os.Getuid()))
selected := got[filepath.Join(root, "pfx")]
if selected.loader != loader {
t.Fatalf("Steam loader = %q, want %q", selected.loader, loader)
}
}
func TestApplicationProcessExcludesWineServices(t *testing.T) {
for _, program := range []string{"services.exe", "winedevice.exe", "svchost.exe", "plugplay.exe", "rpcss.exe", "lsass.exe", "wineboot.exe", "winemenubuilder.exe"} {
if applicationProcess("C:\\windows\\system32\\" + strings.ToUpper(program) + "\x00") {
t.Errorf("Wine service %q counted as an application", program)
}
}
for _, test := range []struct {
commandLine string
want bool
}{
{"", false},
{"C:\\windows\\explorer.exe\x00/desktop\x00", false},
{"C:\\windows\\explorer.exe\x00/DESKTOP=Default,1024x768\x00", false},
{"C:\\windows\\explorer.exe\x00C:\\documents\x00", true},
{"C:\\Program Files\\Application\\app.exe\x00/desktop\x00", true},
{"C:\\windows\\system32\\notepad.exe\x00", true},
{"/usr/bin/wine\x00application.exe\x00", true},
} {
if got := applicationProcess(test.commandLine); got != test.want {
t.Errorf("applicationProcess(%q) = %v, want %v", test.commandLine, got, test.want)
}
}
}
func TestManagerStopsHelperWhenOnlyWineServicesRemain(t *testing.T) {
root := t.TempDir()
proc := filepath.Join(root, "101")
if err := os.Mkdir(proc, 0700); err != nil {
t.Fatal(err)
}
loader := filepath.Join(root, "wine")
helper := filepath.Join(root, "helper")
for _, path := range []string{loader, helper} {
if err := os.WriteFile(path, []byte("#!/bin/sh\nexec sleep 30\n"), 0700); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(loader, filepath.Join(proc, "exe")); err != nil {
t.Fatal(err)
}
prefixPath := filepath.Join(root, "prefix")
if err := os.WriteFile(filepath.Join(proc, "environ"), []byte("WINEPREFIX="+prefixPath+"\x00"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("application.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
manager := &Manager{logger: slog.New(slog.DiscardHandler), helper: helper, procRoot: root}
active := make(map[string]*process)
manager.poll(active)
child := active[prefixPath]
if child == nil || child.cmd == nil {
t.Fatal("application did not start a helper")
}
defer stop(child)
if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("C:\\windows\\system32\\winedevice.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
manager.poll(active)
if child.cmd == nil {
t.Fatal("helper stopped before the idle grace period")
}
child.seen = time.Now().Add(-11 * time.Second)
manager.poll(active)
if len(active) != 0 || child.cmd != nil {
t.Fatal("Wine service kept the helper alive after the idle grace period")
}
manager.poll(active)
if len(active) != 0 {
t.Fatal("Wine service restarted the helper")
}
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package wine
import (
"context"
"errors"
"git.stormux.org/storm/cthulhu/internal/atspi"
"github.com/godbus/dbus/v5"
"time"
)
func (r *ReviewRegistry) Capture(parent context.Context, id atspi.ObjectID) (*atspi.AccessibleTree, error) {
if !IsReviewID(id) {
return nil, ErrReviewUnavailable
}
ctx, cancel := context.WithTimeout(parent, 500*time.Millisecond)
defer cancel()
select {
case r.captureGate <- struct{}{}:
defer func() { <-r.captureGate }()
case <-ctx.Done():
return nil, ctx.Err()
}
for attempt := 0; attempt < 2; attempt++ {
r.mutex.Lock()
p := r.providers[id.BusName]
if p == nil || p.ctx.Err() != nil || p.notice.ID() != id {
r.mutex.Unlock()
return nil, ErrReviewUnavailable
}
notice, lifetime, connection, call := p.notice, p.ctx, r.service, r.captureCall
r.mutex.Unlock()
if connection == nil && call == nil {
return nil, ErrReviewUnavailable
}
callCtx, callCancel := context.WithCancel(ctx)
stop := context.AfterFunc(lifetime, callCancel)
var fields map[string]dbus.Variant
var err error
if call != nil {
fields, err = call(callCtx, id.BusName, notice.Path, notice)
} else {
reply := connection.Object(id.BusName, notice.Path).CallWithContext(callCtx, ReviewInterface+".CaptureWindow", 0, notice.Window, notice.Focus, notice.Revision)
err = reply.Err
if err == nil {
// Store rebuilds variants from their decoded Go values and loses
// tuple signatures. Validate the original owned reply body.
if len(reply.Body) != 1 {
err = ErrReviewInvalid
} else {
var ok bool
fields, ok = reply.Body[0].(map[string]dbus.Variant)
if !ok {
err = ErrReviewInvalid
}
}
}
}
stop()
callCancel()
if err != nil {
return nil, err
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
snapshot, err := DecodeReviewSnapshot(fields)
if errors.Is(err, ErrReviewStale) && attempt == 0 {
continue
}
if err != nil {
return nil, err
}
if snapshot.Instance != notice.Instance || snapshot.Window != notice.Window || snapshot.Focus != notice.Focus || snapshot.Revision != notice.Revision || snapshot.Bounds != notice.Bounds {
return nil, ErrReviewStale
}
r.mutex.Lock()
if r.providers[id.BusName] != p || p.notice != notice || lifetime.Err() != nil {
r.mutex.Unlock()
return nil, ErrReviewStale
}
tree, _, err := ApplyReviewSnapshot(p.trees[notice.Window], snapshot, id.BusName)
if err == nil {
p.trees[notice.Window] = tree
}
r.mutex.Unlock()
return tree, err
}
return nil, ErrReviewStale
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package wine
import (
"encoding/hex"
"errors"
"strings"
"git.stormux.org/storm/cthulhu/internal/atspi"
"github.com/godbus/dbus/v5"
)
const ReviewProtocolVersion uint32 = 1
const ReviewObjectPath dbus.ObjectPath = "/org/stormux/Cthulhu1/WineReview"
const ReviewInterface = "org.stormux.Cthulhu1.WineReview"
var (
ErrReviewUnavailable = errors.New("Wine review unavailable")
ErrReviewStale = errors.New("Wine review source changed")
ErrReviewBusy = errors.New("Wine review capture busy")
ErrReviewLimited = errors.New("Wine review capture exceeds limits")
ErrReviewInvalid = errors.New("invalid Wine review snapshot")
)
// Notices are copied by the registry. Sender and Path are supplied by actual
// registration, never by native payloads. The event loop owns their eligibility.
type ReviewFocusNotice struct {
Sender string
Path dbus.ObjectPath
Instance, Window, Focus string
Revision uint64
Title, Role string
Protected bool
Bounds atspi.Rectangle
}
type ReviewRange struct {
Start, End, UTF16Start, UTF16End uint64
Text string
Bounds atspi.Rectangle
}
type ReviewNode struct {
ID, Parent, Role, Name string
Protected, Visible, TextKnown, CaretKnown bool
Caret, CaretUTF16 uint64
Bounds atspi.Rectangle
Ranges []ReviewRange
}
type ReviewSnapshot struct {
Version uint32
Instance, Window, Focus string
Revision uint64
Status string
Bounds atspi.Rectangle
Nodes []ReviewNode
}
func ReviewID(sender, instance, window, node string) atspi.ObjectID {
encode := func(value string) string { return hex.EncodeToString([]byte(value)) }
return atspi.NewObjectID(sender, "/wine/"+encode(instance)+"/"+encode(window)+"/"+encode(node))
}
func IsReviewID(id atspi.ObjectID) bool {
if !id.Valid() || !strings.HasPrefix(id.BusName, ":") {
return false
}
parts := strings.Split(id.Path, "/")
if len(parts) != 5 || parts[0] != "" || parts[1] != "wine" {
return false
}
for _, part := range parts[2:] {
if part == "" || len(part) > 512 {
return false
}
if _, err := hex.DecodeString(part); err != nil {
return false
}
}
return true
}
func (notice ReviewFocusNotice) ID() atspi.ObjectID {
return ReviewID(notice.Sender, notice.Instance, notice.Window, notice.Focus)
}
+228
View File
@@ -0,0 +1,228 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package wine
import (
"context"
"errors"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/sessionbus"
"github.com/godbus/dbus/v5"
"log/slog"
"sort"
"strings"
"sync"
)
type reviewProvider struct {
path dbus.ObjectPath
notice ReviewFocusNotice
ctx context.Context
cancel context.CancelFunc
trees map[string]*atspi.AccessibleTree
}
type ReviewRegistry struct {
logger *slog.Logger
mutex sync.Mutex
ctx context.Context
cancel context.CancelFunc
monitor, service *dbus.Conn
signals chan *dbus.Signal
done chan struct{}
changes chan struct{}
providers map[string]*reviewProvider
captureGate chan struct{}
captureCall func(context.Context, string, dbus.ObjectPath, ReviewFocusNotice) (map[string]dbus.Variant, error)
}
func NewReviewRegistry(logger *slog.Logger) *ReviewRegistry {
return &ReviewRegistry{logger: logger, changes: make(chan struct{}, 1), providers: make(map[string]*reviewProvider), captureGate: make(chan struct{}, 1)}
}
func (r *ReviewRegistry) notify() {
select {
case r.changes <- struct{}{}:
default:
}
}
func (r *ReviewRegistry) Changes() <-chan struct{} { return r.changes }
func (r *ReviewRegistry) Start(ctx context.Context) error {
if ctx == nil {
return errors.New("Wine review requires a context")
}
conn, err := sessionbus.Connect(r.logger, "wine-review")
if err != nil {
return err
}
options := []dbus.MatchOption{dbus.WithMatchSender("org.freedesktop.DBus"), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged")}
signals := make(chan *dbus.Signal, 64)
conn.Signal(signals)
if err = conn.AddMatchSignal(options...); err != nil {
conn.RemoveSignal(signals)
conn.Close()
return err
}
r.mutex.Lock()
if r.ctx != nil {
r.mutex.Unlock()
conn.RemoveSignal(signals)
conn.Close()
return errors.New("Wine review already started")
}
r.ctx, r.cancel = context.WithCancel(ctx)
r.monitor = conn
r.signals = signals
r.done = make(chan struct{})
lifetime, done := r.ctx, r.done
r.mutex.Unlock()
go func() {
defer close(done)
for {
select {
case <-lifetime.Done():
return
case <-conn.Context().Done():
r.retireAll()
return
case signal := <-signals:
if signal != nil && len(signal.Body) == 3 {
name, ok := signal.Body[0].(string)
old, _ := signal.Body[1].(string)
next, _ := signal.Body[2].(string)
if ok && name == old && next == "" {
r.disappeared(name)
}
}
}
}
}()
return nil
}
// The service connection is borrowed because native capture accepts only the
// current owner of Cthulhu's well-known name. Registry never closes it.
func (r *ReviewRegistry) BindWineReviewConnection(conn *dbus.Conn) {
r.mutex.Lock()
r.service = conn
if conn == nil {
for key, p := range r.providers {
p.cancel()
delete(r.providers, key)
}
}
r.notify()
r.mutex.Unlock()
}
func (r *ReviewRegistry) Stop(ctx context.Context) error {
r.mutex.Lock()
if r.cancel != nil {
r.cancel()
}
conn, signals, done := r.monitor, r.signals, r.done
for key, p := range r.providers {
p.cancel()
delete(r.providers, key)
}
r.ctx = nil
r.cancel = nil
r.monitor = nil
r.service = nil
r.signals = nil
r.done = nil
r.notify()
r.mutex.Unlock()
if conn != nil {
conn.RemoveSignal(signals)
conn.RemoveMatchSignal(dbus.WithMatchSender("org.freedesktop.DBus"), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged"))
conn.Close()
}
if done != nil {
select {
case <-done:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}
func (r *ReviewRegistry) RegisterProvider(sender string, path dbus.ObjectPath, version uint32) bool {
if !strings.HasPrefix(sender, ":") || !dbus.ObjectPath("/"+strings.ReplaceAll(sender[1:], ".", "/")).IsValid() || path != ReviewObjectPath || version != ReviewProtocolVersion {
return false
}
r.mutex.Lock()
defer r.mutex.Unlock()
if r.ctx == nil || r.ctx.Err() != nil {
return false
}
if old := r.providers[sender]; old != nil {
old.cancel()
}
ctx, cancel := context.WithCancel(r.ctx)
r.providers[sender] = &reviewProvider{path: path, ctx: ctx, cancel: cancel, trees: make(map[string]*atspi.AccessibleTree)}
r.notify()
return true
}
func (r *ReviewRegistry) PresentFocus(sender string, notice ReviewFocusNotice) bool {
if !reviewString(notice.Instance, 256, false) || !reviewString(notice.Window, 256, false) || !reviewString(notice.Focus, 256, false) || notice.Revision == 0 || !reviewString(notice.Title, 4096, true) || !reviewString(notice.Role, 4096, false) || !notice.Bounds.Valid() {
return false
}
r.mutex.Lock()
defer r.mutex.Unlock()
p := r.providers[sender]
if p == nil || p.ctx.Err() != nil {
return false
}
notice.Sender = sender
notice.Path = p.path
if p.notice == notice {
return true
}
if p.notice.Instance == notice.Instance && p.notice.Revision > notice.Revision {
return false
}
if p.notice.Instance != notice.Instance || p.notice.Window != notice.Window {
// Only the active native window can be captured. Retiring older
// windows bounds retained text and preserves the current tree's identity.
p.trees = make(map[string]*atspi.AccessibleTree)
}
p.cancel()
p.ctx, p.cancel = context.WithCancel(r.ctx)
p.notice = notice
r.notify()
return true
}
func (r *ReviewRegistry) Focuses() []ReviewFocusNotice {
r.mutex.Lock()
defer r.mutex.Unlock()
var notices []ReviewFocusNotice
for _, p := range r.providers {
if p.notice.Focus != "" && p.ctx.Err() == nil {
notices = append(notices, p.notice)
}
}
sort.Slice(notices, func(i, j int) bool { return notices[i].Sender < notices[j].Sender })
return notices
}
func (r *ReviewRegistry) Current(id atspi.ObjectID) bool {
r.mutex.Lock()
defer r.mutex.Unlock()
p := r.providers[id.BusName]
return IsReviewID(id) && p != nil && p.ctx.Err() == nil && p.notice.ID() == id
}
func (r *ReviewRegistry) disappeared(sender string) {
r.mutex.Lock()
defer r.mutex.Unlock()
if p := r.providers[sender]; p != nil {
p.cancel()
delete(r.providers, sender)
r.notify()
}
}
func (r *ReviewRegistry) retireAll() {
r.mutex.Lock()
defer r.mutex.Unlock()
for sender, p := range r.providers {
p.cancel()
delete(r.providers, sender)
}
r.notify()
}
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package wine
import (
"context"
"errors"
"fmt"
"git.stormux.org/storm/cthulhu/internal/atspi"
"github.com/godbus/dbus/v5"
"testing"
"time"
)
func TestReviewRegistryRetiresInactiveWindowTrees(t *testing.T) {
r := readyReviewRegistry(t)
n := registerNotice(t, r, ":1.2")
r.captureCall = func(_ context.Context, _ string, _ dbus.ObjectPath, notice ReviewFocusNotice) (map[string]dbus.Variant, error) {
f := reviewWire()
f["window"] = dbus.MakeVariant(notice.Window)
f["revision"] = dbus.MakeVariant(notice.Revision)
nodes := f["nodes"].Value().([]map[string]dbus.Variant)
nodes[0]["id"] = dbus.MakeVariant(notice.Window)
for _, node := range nodes[1:] {
if node["parent"].Value() == "window" {
node["parent"] = dbus.MakeVariant(notice.Window)
}
}
return f, nil
}
for i := 1; i <= 32; i++ {
n.Window = fmt.Sprintf("window%d", i)
n.Revision = uint64(i)
if !r.PresentFocus(n.Sender, n) {
t.Fatal("focus rejected")
}
one, err := r.Capture(context.Background(), n.ID())
if err != nil {
t.Fatal(err)
}
two, err := r.Capture(context.Background(), n.ID())
if err != nil || one != two {
t.Fatal("active window tree is not stable")
}
if len(r.providers[n.Sender].trees) != 1 {
t.Fatalf("retained %d inactive trees", len(r.providers[n.Sender].trees)-1)
}
}
}
func registryNotice(sender string) ReviewFocusNotice {
return ReviewFocusNotice{Sender: sender, Path: ReviewObjectPath, Instance: "helper-instance", Window: "window", Focus: "input", Revision: 1, Title: "same title", Role: "window", Bounds: atspi.Rectangle{X: 10, Y: 20, Width: 400, Height: 300}}
}
func readyReviewRegistry(t *testing.T) *ReviewRegistry {
t.Helper()
registry := NewReviewRegistry(nil)
// Unit tests substitute transport only; registration still requires lifecycle.
registry.ctx, registry.cancel = context.WithCancel(context.Background())
t.Cleanup(func() { registry.Stop(context.Background()) })
return registry
}
func registerNotice(t *testing.T, r *ReviewRegistry, sender string) ReviewFocusNotice {
t.Helper()
notice := registryNotice(sender)
if !r.RegisterProvider(sender, ReviewObjectPath, 1) || !r.PresentFocus(sender, notice) {
t.Fatal("registration rejected")
}
return notice
}
func TestReviewRegistryTwoProviders(t *testing.T) {
r := readyReviewRegistry(t)
one := registerNotice(t, r, ":1.2")
two := registerNotice(t, r, ":1.3")
if one.ID() == two.ID() || len(r.Focuses()) != 2 || !r.Current(one.ID()) || !r.Current(two.ID()) {
t.Fatal("providers collided")
}
if len(r.changes) != 1 {
t.Fatal("notifications are not coalesced")
}
if !r.RegisterProvider(one.Sender, ReviewObjectPath, 1) || r.Current(one.ID()) || len(r.Focuses()) != 1 {
t.Fatal("registration retained old lifetime")
}
}
func TestReviewRegistrySenderAssociation(t *testing.T) {
r := readyReviewRegistry(t)
n := registryNotice(":1.2")
if r.PresentFocus(n.Sender, n) || r.RegisterProvider("well.known", ReviewObjectPath, 1) || r.RegisterProvider(n.Sender, ReviewObjectPath, 2) || r.RegisterProvider(n.Sender, "/wrong", 1) {
t.Fatal("invalid provider accepted")
}
registerNotice(t, r, n.Sender)
n.Sender = ":1.99"
n.Path = "/forged"
if !r.PresentFocus(":1.2", n) {
t.Fatal("actual sender rejected")
}
got := r.Focuses()[0]
if got.Sender != ":1.2" || got.Path != ReviewObjectPath {
t.Fatal("payload forged destination")
}
n.Revision = 0
if r.PresentFocus(":1.2", n) {
t.Fatal("invalid focus accepted")
}
}
func TestReviewRegistryProviderDisappears(t *testing.T) {
r := readyReviewRegistry(t)
n := registerNotice(t, r, ":1.2")
r.disappeared(n.Sender)
if r.Current(n.ID()) || len(r.Focuses()) != 0 {
t.Fatal("dead provider retained")
}
if _, err := r.Capture(context.Background(), n.ID()); !errors.Is(err, ErrReviewUnavailable) {
t.Fatal(err)
}
}
func TestReviewCaptureDeadlineAndStaleReply(t *testing.T) {
r := readyReviewRegistry(t)
n := registerNotice(t, r, ":1.2")
calls := 0
r.captureCall = func(ctx context.Context, sender string, path dbus.ObjectPath, notice ReviewFocusNotice) (map[string]dbus.Variant, error) {
calls++
fields := reviewWire()
if calls == 1 {
fields["status"] = dbus.MakeVariant("stale")
fields["nodes"] = dbus.MakeVariant([]map[string]dbus.Variant{})
}
return fields, nil
}
tree, err := r.Capture(context.Background(), n.ID())
if err != nil || tree == nil || calls != 2 {
t.Fatalf("stale retry: %v calls=%d", err, calls)
}
again, err := r.Capture(context.Background(), n.ID())
if err != nil || again != tree {
t.Fatal("capture replaced live tree")
}
r.captureCall = func(ctx context.Context, _ string, _ dbus.ObjectPath, _ ReviewFocusNotice) (map[string]dbus.Variant, error) {
<-ctx.Done()
return nil, ctx.Err()
}
started := time.Now()
if _, err = r.Capture(context.Background(), n.ID()); err == nil || time.Since(started) > 750*time.Millisecond {
t.Fatalf("deadline: %v", err)
}
r.captureCall = func(context.Context, string, dbus.ObjectPath, ReviewFocusNotice) (map[string]dbus.Variant, error) {
f := reviewWire()
f["revision"] = dbus.MakeVariant(uint64(2))
return f, nil
}
if _, err = r.Capture(context.Background(), n.ID()); !errors.Is(err, ErrReviewStale) {
t.Fatalf("wrong source: %v", err)
}
}
func TestReviewCaptureStopCancels(t *testing.T) {
r := readyReviewRegistry(t)
n := registerNotice(t, r, ":1.2")
entered := make(chan struct{})
done := make(chan error, 1)
r.captureCall = func(ctx context.Context, _ string, _ dbus.ObjectPath, _ ReviewFocusNotice) (map[string]dbus.Variant, error) {
close(entered)
<-ctx.Done()
return nil, ctx.Err()
}
go func() { _, err := r.Capture(context.Background(), n.ID()); done <- err }()
<-entered
r.Stop(context.Background())
select {
case err := <-done:
if err == nil {
t.Fatal("cancelled capture accepted")
}
case <-time.After(time.Second):
t.Fatal("stop blocked")
}
}
+331
View File
@@ -0,0 +1,331 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package wine
import (
"encoding/binary"
"fmt"
"reflect"
"strings"
"unicode/utf16"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
"github.com/godbus/dbus/v5"
)
const maxReviewSourceUnits uint64 = 1000000
func reviewString(value string, limit int, empty bool) bool {
return (empty || value != "") && len(value) <= limit && utf8.ValidString(value) && !strings.ContainsRune(value, 0)
}
func reviewField(fields map[string]dbus.Variant, key, signature string, destination any) error {
value, found := fields[key]
if !found || value.Signature().String() != signature {
return fmt.Errorf("%w: field %s signature %s (expected %s)", ErrReviewInvalid, key, value.Signature(), signature)
}
if !copyReviewValue(reflect.ValueOf(destination).Elem(), reflect.ValueOf(value.Value())) {
return fmt.Errorf("%w: field %s", ErrReviewInvalid, key)
}
return nil
}
// D-Bus tuples arrive as []interface{}. Accept that representation and typed
// producer records, but never coerce a signed integer or a different wire type.
func copyReviewValue(dst, src reflect.Value) bool {
for src.IsValid() && src.Kind() == reflect.Interface {
src = src.Elem()
}
if !src.IsValid() {
return false
}
switch dst.Kind() {
case reflect.Struct:
if src.Kind() != reflect.Struct && src.Type() != reflect.TypeOf([]any{}) {
return false
}
n := src.Len
if src.Kind() == reflect.Struct {
n = src.NumField
}
if n() != dst.NumField() {
return false
}
for i := 0; i < dst.NumField(); i++ {
var field reflect.Value
if src.Kind() == reflect.Struct {
field = src.Field(i)
} else {
field = src.Index(i)
}
if !copyReviewValue(dst.Field(i), field) {
return false
}
}
return true
case reflect.Slice:
if src.Kind() != reflect.Slice || src.Len() > 256 {
return false
}
result := reflect.MakeSlice(dst.Type(), src.Len(), src.Len())
for i := 0; i < src.Len(); i++ {
if !copyReviewValue(result.Index(i), src.Index(i)) {
return false
}
}
dst.Set(result)
return true
default:
if src.Type() != dst.Type() {
return false
}
dst.Set(src)
return true
}
}
func copyReviewFields(fields map[string]dbus.Variant) map[string]dbus.Variant {
copy := make(map[string]dbus.Variant, len(fields))
for key, value := range fields {
copy[key] = value
}
return copy
}
func DecodeReviewSnapshot(fields map[string]dbus.Variant) (ReviewSnapshot, error) {
var snapshot ReviewSnapshot
if len(fields) != 8 {
return ReviewSnapshot{}, ErrReviewInvalid
}
reads := []struct {
key, signature string
destination any
}{
{"version", "u", &snapshot.Version}, {"instance", "s", &snapshot.Instance}, {"window", "s", &snapshot.Window},
{"focus", "s", &snapshot.Focus}, {"revision", "t", &snapshot.Revision}, {"status", "s", &snapshot.Status}, {"bounds", "(iiii)", &snapshot.Bounds},
}
for _, read := range reads {
if err := reviewField(fields, read.key, read.signature, read.destination); err != nil {
return ReviewSnapshot{}, err
}
}
var nodes []map[string]dbus.Variant
// Check the outer shape and count before copying nested records.
value, found := fields["nodes"]
if !found || value.Signature().String() != "aa{sv}" {
return ReviewSnapshot{}, ErrReviewInvalid
}
nodes, found = value.Value().([]map[string]dbus.Variant)
if !found || len(nodes) > 4096 {
return ReviewSnapshot{}, ErrReviewInvalid
}
canonical := copyReviewFields(fields)
canonical["bounds"] = dbus.MakeVariant(snapshot.Bounds)
canonicalNodes := make([]map[string]dbus.Variant, 0, len(nodes))
for _, fields := range nodes {
if len(fields) != 12 {
return ReviewSnapshot{}, ErrReviewInvalid
}
var node ReviewNode
reads := []struct {
key, signature string
destination any
}{
{"id", "s", &node.ID}, {"parent", "s", &node.Parent}, {"role", "s", &node.Role}, {"name", "s", &node.Name},
{"protected", "b", &node.Protected}, {"visible", "b", &node.Visible}, {"text_known", "b", &node.TextKnown},
{"caret_known", "b", &node.CaretKnown}, {"caret", "t", &node.Caret}, {"caret_utf16", "t", &node.CaretUTF16},
{"bounds", "(iiii)", &node.Bounds}, {"ranges", "a(tttts(iiii))", &node.Ranges},
}
for _, read := range reads {
if err := reviewField(fields, read.key, read.signature, read.destination); err != nil {
return ReviewSnapshot{}, err
}
}
snapshot.Nodes = append(snapshot.Nodes, node)
encoded := copyReviewFields(fields)
encoded["bounds"] = dbus.MakeVariant(node.Bounds)
encoded["ranges"] = dbus.MakeVariant(node.Ranges)
canonicalNodes = append(canonicalNodes, encoded)
}
if err := validateReviewSnapshot(snapshot); err != nil {
snapshot.Nodes = nil
return snapshot, err
}
// Exact D-Bus encoding catches metadata overhead after the independent
// record/text budgets bound the encoder's temporary allocation.
counter := reviewByteCounter{}
canonical["nodes"] = dbus.MakeVariant(canonicalNodes)
message := dbus.Message{Type: dbus.TypeMethodReply, Headers: map[dbus.HeaderField]dbus.Variant{
dbus.FieldReplySerial: dbus.MakeVariant(uint32(1)), dbus.FieldSignature: dbus.MakeVariant(dbus.SignatureOf(fields)),
}, Body: []any{canonical}}
if err := message.EncodeTo(&counter, binary.LittleEndian); err != nil || counter.count > 2<<20 {
return ReviewSnapshot{}, ErrReviewInvalid
}
return snapshot, nil
}
type reviewByteCounter struct{ count int }
func (counter *reviewByteCounter) Write(data []byte) (int, error) {
counter.count += len(data)
return len(data), nil
}
func validateReviewSnapshot(snapshot ReviewSnapshot) error {
if snapshot.Version != ReviewProtocolVersion || !reviewString(snapshot.Instance, 256, false) ||
!reviewString(snapshot.Window, 256, false) || !reviewString(snapshot.Focus, 256, false) || snapshot.Revision == 0 || !snapshot.Bounds.Valid() || len(snapshot.Nodes) > 4096 {
return ErrReviewInvalid
}
switch snapshot.Status {
case "ok":
case "unavailable", "stale", "busy", "limited":
if len(snapshot.Nodes) != 0 {
return ErrReviewInvalid
}
switch snapshot.Status {
case "stale":
return ErrReviewStale
case "busy":
return ErrReviewBusy
case "limited":
return ErrReviewLimited
default:
return ErrReviewUnavailable
}
default:
return ErrReviewInvalid
}
if len(snapshot.Nodes) == 0 {
return ErrReviewInvalid
}
nodes := make(map[string]ReviewNode, len(snapshot.Nodes))
textBytes, metadataFloor := 0, 256
for _, node := range snapshot.Nodes {
if !reviewString(node.ID, 256, false) || !reviewString(node.Parent, 256, true) || !reviewString(node.Role, 4096, false) ||
!reviewString(node.Name, 4096, true) || !node.Bounds.Valid() || len(node.Ranges) > 256 {
return ErrReviewInvalid
}
if _, exists := nodes[node.ID]; exists {
return ErrReviewInvalid
}
nodes[node.ID] = node
if node.Protected && (node.Name != "" || node.TextKnown || node.CaretKnown || len(node.Ranges) != 0 || node.Caret != 0 || node.CaretUTF16 != 0) {
return ErrReviewInvalid
}
if !node.Visible && (node.Name != "" || node.TextKnown || node.CaretKnown || len(node.Ranges) != 0) {
return ErrReviewInvalid
}
if !node.TextKnown && (len(node.Ranges) != 0 || node.CaretKnown) || !node.CaretKnown && (node.Caret != 0 || node.CaretUTF16 != 0) {
return ErrReviewInvalid
}
if node.Caret > maxReviewSourceUnits || node.CaretUTF16 > maxReviewSourceUnits || node.CaretUTF16 < node.Caret || node.CaretUTF16 > 2*node.Caret {
return ErrReviewInvalid
}
previous, nativePrevious, characters := uint64(0), uint64(0), uint64(0)
textBytes += len(node.Name)
metadataFloor += 200 + len(node.ID) + len(node.Parent) + len(node.Role) + len(node.Name)
for _, r := range node.Ranges {
if !utf8.ValidString(r.Text) || strings.ContainsAny(r.Text, "\x00\ufffc") || r.Start < previous || r.End <= r.Start ||
r.UTF16Start < nativePrevious || r.UTF16End <= r.UTF16Start || r.End > maxReviewSourceUnits || r.UTF16End > maxReviewSourceUnits ||
r.UTF16Start < r.Start || r.UTF16Start > 2*r.Start || r.UTF16End < r.End || r.UTF16End > 2*r.End {
return ErrReviewInvalid
}
count := uint64(utf8.RuneCountInString(r.Text))
if count != r.End-r.Start || count > 4096 || uint64(len(utf16.Encode([]rune(r.Text)))) != r.UTF16End-r.UTF16Start {
return ErrReviewInvalid
}
if strings.ContainsAny(r.Text, "\r\n") && strings.Trim(r.Text, "\r\n") != "" {
return ErrReviewInvalid
}
if clipped, ok := r.Bounds.Intersection(node.Bounds); !ok || clipped != r.Bounds {
return ErrReviewInvalid
}
previous, nativePrevious = r.End, r.UTF16End
characters += count
textBytes += len(r.Text)
metadataFloor += 64 + len(r.Text)
}
if characters > 65536 || textBytes > 1<<20 || metadataFloor > 2<<20 {
return ErrReviewInvalid
}
}
root, exists := nodes[snapshot.Window]
if !exists || root.Parent != "" || (root.Role != "window" && root.Role != "dialog") || !root.Visible || root.Protected || root.Bounds != snapshot.Bounds {
return ErrReviewInvalid
}
if _, exists := nodes[snapshot.Focus]; !exists {
return ErrReviewInvalid
}
// Check each ancestry chain: this bounds depth and detects disconnected
// cycles, which a traversal starting only at the root would miss.
for id, node := range nodes {
if id == snapshot.Window {
continue
}
seen := map[string]bool{id: true}
for depth := 1; ; depth++ {
if depth > 64 {
return ErrReviewInvalid
}
parent, exists := nodes[node.Parent]
if !exists || seen[node.Parent] {
return ErrReviewInvalid
}
if clipped, ok := node.Bounds.Intersection(parent.Bounds); !ok || clipped != node.Bounds {
return ErrReviewInvalid
}
if parent.ID == snapshot.Window {
break
}
seen[parent.ID] = true
node = parent
}
}
return nil
}
func ApplyReviewSnapshot(tree *atspi.AccessibleTree, snapshot ReviewSnapshot, sender string) (*atspi.AccessibleTree, atspi.ObjectID, error) {
if err := validateReviewSnapshot(snapshot); err != nil {
return nil, atspi.ObjectID{}, err
}
root := ReviewID(sender, snapshot.Instance, snapshot.Window, snapshot.Window)
if !IsReviewID(root) {
return nil, atspi.ObjectID{}, ErrReviewInvalid
}
nodes := make([]atspi.TreeNode, len(snapshot.Nodes))
index := make(map[string]int, len(nodes))
for i, node := range snapshot.Nodes {
id := ReviewID(sender, snapshot.Instance, snapshot.Window, node.ID)
index[node.ID] = i
role := node.Role
if node.Protected {
role = "password text"
}
geometry := atspi.Geometry{VisibilityKnown: true, Visible: node.Visible, Showing: node.Visible}
if node.Visible {
geometry.BoundsKnown = true
geometry.Bounds = node.Bounds
}
text := atspi.VisibleText{Known: node.TextKnown, CaretKnown: node.CaretKnown, CaretOffset: int(node.Caret)}
for _, r := range node.Ranges {
text.Ranges = append(text.Ranges, atspi.TextRange{Start: int(r.Start), End: int(r.End), Text: r.Text, Bounds: r.Bounds})
}
nodes[i] = atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: id.BusName, Path: id.Path, Name: node.Name, Role: role,
StatesKnown: true, Visible: node.Visible, Showing: node.Visible, Focused: node.ID == snapshot.Focus, Focusable: node.ID == snapshot.Focus}, Geometry: geometry, Text: text}
}
for _, node := range snapshot.Nodes {
if node.Parent != "" {
parent := index[node.Parent]
nodes[parent].Children = append(nodes[parent].Children, ReviewID(sender, snapshot.Instance, snapshot.Window, node.ID))
}
}
var err error
if tree == nil {
tree, err = atspi.NewAccessibleTree(root, nodes)
} else {
err = tree.Replace(root, nodes)
}
if err != nil {
return nil, atspi.ObjectID{}, fmt.Errorf("%w: %v", ErrReviewInvalid, err)
}
return tree, ReviewID(sender, snapshot.Instance, snapshot.Window, snapshot.Focus), nil
}
+352
View File
@@ -0,0 +1,352 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package wine
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"strings"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
"github.com/godbus/dbus/v5"
)
// These records describe the producer's wire contract independently of the
// decoder's types. This also exercises godbus struct-to-struct conversion.
type testWireRange struct {
Start, End, UTF16Start, UTF16End uint64
Text string
Bounds atspi.Rectangle
}
func reviewWireNode(id, parent, role, name string, bounds atspi.Rectangle, known bool, ranges ...testWireRange) map[string]dbus.Variant {
return map[string]dbus.Variant{
"id": dbus.MakeVariant(id), "parent": dbus.MakeVariant(parent), "role": dbus.MakeVariant(role), "name": dbus.MakeVariant(name),
"protected": dbus.MakeVariant(false), "visible": dbus.MakeVariant(true), "bounds": dbus.MakeVariant(bounds),
"text_known": dbus.MakeVariant(known), "caret_known": dbus.MakeVariant(false), "caret": dbus.MakeVariant(uint64(0)),
"caret_utf16": dbus.MakeVariant(uint64(0)), "ranges": dbus.MakeVariant(ranges),
}
}
func reviewWire() map[string]dbus.Variant {
root := atspi.Rectangle{X: 10, Y: 20, Width: 400, Height: 300}
input := reviewWireNode("input", "window", "text", "", atspi.Rectangle{X: 20, Y: 40, Width: 200, Height: 30}, true,
testWireRange{0, 11, 0, 12, "😀alpha beta", atspi.Rectangle{X: 20, Y: 40, Width: 140, Height: 20}})
input["caret_known"] = dbus.MakeVariant(true)
input["caret"] = dbus.MakeVariant(uint64(2))
input["caret_utf16"] = dbus.MakeVariant(uint64(3))
password := reviewWireNode("password", "window", "password text", "", atspi.Rectangle{X: 20, Y: 160, Width: 120, Height: 25}, false)
password["protected"] = dbus.MakeVariant(true)
nodes := []map[string]dbus.Variant{
reviewWireNode("window", "", "window", "Review fixture", root, false), input,
reviewWireNode("output", "window", "text", "", atspi.Rectangle{X: 20, Y: 100, Width: 200, Height: 50}, true,
testWireRange{7, 11, 8, 12, "beta", atspi.Rectangle{X: 20, Y: 100, Width: 80, Height: 20}}), password,
reviewWireNode("button", "window", "push button", "Open & close", atspi.Rectangle{X: 20, Y: 210, Width: 120, Height: 25}, false),
}
return map[string]dbus.Variant{"version": dbus.MakeVariant(uint32(1)), "instance": dbus.MakeVariant("helper-instance"),
"window": dbus.MakeVariant("window"), "focus": dbus.MakeVariant("input"), "revision": dbus.MakeVariant(uint64(1)),
"status": dbus.MakeVariant("ok"), "bounds": dbus.MakeVariant(root), "nodes": dbus.MakeVariant(nodes)}
}
func wireNodes(fields map[string]dbus.Variant) []map[string]dbus.Variant {
return fields["nodes"].Value().([]map[string]dbus.Variant)
}
func TestReviewSnapshotUnicodeAndStableTree(t *testing.T) {
fields := reviewWire()
snapshot, err := DecodeReviewSnapshot(fields)
if err != nil {
t.Fatal(err)
}
tree, focus, err := ApplyReviewSnapshot(nil, snapshot, ":1.20")
if err != nil {
t.Fatal(err)
}
if !focus.Valid() || !IsReviewID(focus) || !strings.HasPrefix(focus.Path, "/wine/") {
t.Fatalf("invalid Wine identity: %v", focus)
}
reference, err := tree.Reference(focus)
if err != nil {
t.Fatal(err)
}
visible, err := tree.VisibleText(reference)
if err != nil {
t.Fatal(err)
}
if !visible.CaretKnown || visible.CaretOffset != 2 || len(visible.Ranges) != 1 || visible.Ranges[0].End != 11 || visible.Ranges[0].Text != "😀alpha beta" {
t.Fatalf("bad Unicode capture: %+v", visible)
}
original := reference
input := wireNodes(fields)[1]
input["ranges"] = dbus.MakeVariant([]testWireRange{{0, 11, 0, 12, "😀omega zeta", atspi.Rectangle{X: 20, Y: 40, Width: 140, Height: 20}}})
snapshot, err = DecodeReviewSnapshot(fields)
if err != nil {
t.Fatal(err)
}
updated, again, err := ApplyReviewSnapshot(tree, snapshot, ":1.20")
if err != nil {
t.Fatal(err)
}
if updated != tree || again != focus {
t.Fatal("text update replaced the owned tree or focus identity")
}
after, _ := tree.Reference(focus)
if after != original {
t.Fatal("text update retired a live generation")
}
visible, _ = tree.VisibleText(after)
if visible.Ranges[0].Text != "😀omega zeta" {
t.Fatal("tree retained old text")
}
// Removing and then reusing an identity must not revive its old reference.
nodes := wireNodes(fields)
fields["focus"] = dbus.MakeVariant("output")
fields["nodes"] = dbus.MakeVariant(append(append([]map[string]dbus.Variant{}, nodes[:1]...), nodes[2:]...))
snapshot, err = DecodeReviewSnapshot(fields)
if err != nil {
t.Fatal(err)
}
if _, _, err = ApplyReviewSnapshot(tree, snapshot, ":1.20"); err != nil {
t.Fatal(err)
}
fields["nodes"] = dbus.MakeVariant(nodes)
fields["focus"] = dbus.MakeVariant("input")
snapshot, _ = DecodeReviewSnapshot(fields)
if _, _, err = ApplyReviewSnapshot(tree, snapshot, ":1.20"); err != nil {
t.Fatal(err)
}
after, _ = tree.Reference(focus)
if after == original {
t.Fatal("removed identity reused an old generation")
}
if _, err = tree.Resolve(original); !errors.Is(err, atspi.ErrStaleObject) {
t.Fatalf("removed reference stayed usable: %v", err)
}
// The unique sender is part of every owned identity.
_, other, err := ApplyReviewSnapshot(nil, snapshot, ":1.21")
if err != nil || other == focus {
t.Fatalf("providers collided: %v", err)
}
for _, id := range []atspi.ObjectID{atspi.NewObjectID(":1.20", "/wineish/a/b/c"), atspi.NewObjectID(":1.20", "/wine/x/y/z"), atspi.NewObjectID("org.test.App", focus.Path)} {
if IsReviewID(id) {
t.Fatalf("reserved namespace accepted invalid identity: %+v", id)
}
}
}
func TestReviewSnapshotEmptyAnchor(t *testing.T) {
fields := reviewWire()
input := wireNodes(fields)[1]
input["ranges"] = dbus.MakeVariant([]testWireRange{})
input["caret"] = dbus.MakeVariant(uint64(0))
input["caret_utf16"] = dbus.MakeVariant(uint64(0))
snapshot, err := DecodeReviewSnapshot(fields)
if err != nil {
t.Fatal(err)
}
tree, focus, err := ApplyReviewSnapshot(nil, snapshot, ":1.20")
if err != nil {
t.Fatal(err)
}
ref, err := tree.Reference(focus)
if err != nil {
t.Fatal(err)
}
text, err := tree.VisibleText(ref)
if err != nil || !text.Known || len(text.Ranges) != 0 {
t.Fatalf("empty anchor lost: %+v %v", text, err)
}
window, err := tree.ReviewWindow(context.Background(), focus)
if err != nil || len(window.Nodes) != 5 {
t.Fatalf("empty anchor hid output: %v", err)
}
}
func TestReviewSnapshotProtectedContent(t *testing.T) {
fields := reviewWire()
fields["focus"] = dbus.MakeVariant("password")
snapshot, err := DecodeReviewSnapshot(fields)
if err != nil {
t.Fatal(err)
}
tree, focus, err := ApplyReviewSnapshot(nil, snapshot, ":1.20")
if err != nil {
t.Fatal(err)
}
ref, _ := tree.Reference(focus)
object, _ := tree.Resolve(ref)
text, _ := tree.VisibleText(ref)
if object.Role != "password text" || object.Name != "" || object.Text != "" || text.Known || len(text.Ranges) != 0 {
t.Fatalf("protected object exposed text: %+v %+v", object, text)
}
wireNodes(fields)[3]["name"] = dbus.MakeVariant("private-review-secret")
rejected, err := DecodeReviewSnapshot(fields)
if !errors.Is(err, ErrReviewInvalid) || len(rejected.Nodes) != 0 {
t.Fatal("protected name accepted")
}
}
func TestReviewSnapshotRejectsInvalidPayload(t *testing.T) {
tests := map[string]func(map[string]dbus.Variant){
"forged scalar signature": func(f map[string]dbus.Variant) {
f["revision"] = dbus.MakeVariantWithSignature(int64(1), dbus.ParseSignatureMust("t"))
},
"forged tuple signature": func(f map[string]dbus.Variant) {
f["bounds"] = dbus.MakeVariantWithSignature([]int64{10, 20, 400, 300}, dbus.ParseSignatureMust("(iiii)"))
},
"version": func(f map[string]dbus.Variant) { f["version"] = dbus.MakeVariant(uint32(2)) },
"wrong scalar": func(f map[string]dbus.Variant) { f["revision"] = dbus.MakeVariant(int64(1)) },
"missing key": func(f map[string]dbus.Variant) { delete(f, "focus") },
"extra key": func(f map[string]dbus.Variant) { f["extra"] = dbus.MakeVariant("not part of version one") },
"invalid utf8": func(f map[string]dbus.Variant) { f["instance"] = dbus.MakeVariant(string([]byte{255})) },
"nul identity": func(f map[string]dbus.Variant) { f["instance"] = dbus.MakeVariant("a\x00b") },
"identity bound": func(f map[string]dbus.Variant) { f["instance"] = dbus.MakeVariant(strings.Repeat("a", 257)) },
"empty identity": func(f map[string]dbus.Variant) { f["instance"] = dbus.MakeVariant("") },
"unknown status": func(f map[string]dbus.Variant) { f["status"] = dbus.MakeVariant("partial") },
"nonok nodes": func(f map[string]dbus.Variant) { f["status"] = dbus.MakeVariant("limited") },
"invalid bounds": func(f map[string]dbus.Variant) { f["bounds"] = dbus.MakeVariant(atspi.Rectangle{}) },
"bounds shape": func(f map[string]dbus.Variant) { f["bounds"] = dbus.MakeVariant([]int32{1, 2, 3, 4}) },
"nodes shape": func(f map[string]dbus.Variant) { f["nodes"] = dbus.MakeVariant([]string{"input"}) },
"no nodes": func(f map[string]dbus.Variant) { f["nodes"] = dbus.MakeVariant([]map[string]dbus.Variant{}) },
"duplicate": func(f map[string]dbus.Variant) { f["nodes"] = dbus.MakeVariant(append(wireNodes(f), wireNodes(f)[1])) },
"orphan": func(f map[string]dbus.Variant) { wireNodes(f)[1]["parent"] = dbus.MakeVariant("missing") },
"cycle": func(f map[string]dbus.Variant) {
wireNodes(f)[1]["parent"] = dbus.MakeVariant("output")
wireNodes(f)[2]["parent"] = dbus.MakeVariant("input")
},
"missing focus": func(f map[string]dbus.Variant) { f["focus"] = dbus.MakeVariant("missing") },
"root parent": func(f map[string]dbus.Variant) { wireNodes(f)[0]["parent"] = dbus.MakeVariant("input") },
"root role": func(f map[string]dbus.Variant) { wireNodes(f)[0]["role"] = dbus.MakeVariant("text") },
"node type": func(f map[string]dbus.Variant) { wireNodes(f)[1]["caret_known"] = dbus.MakeVariant(uint32(1)) },
"node missing key": func(f map[string]dbus.Variant) { delete(wireNodes(f)[1], "ranges") },
"caption bound": func(f map[string]dbus.Variant) { wireNodes(f)[4]["name"] = dbus.MakeVariant(strings.Repeat("a", 4097)) },
"invisible text": func(f map[string]dbus.Variant) { wireNodes(f)[1]["visible"] = dbus.MakeVariant(false) },
"unknown text with ranges": func(f map[string]dbus.Variant) { wireNodes(f)[1]["text_known"] = dbus.MakeVariant(false) },
"unknown caret": func(f map[string]dbus.Variant) { wireNodes(f)[1]["caret_known"] = dbus.MakeVariant(false) },
"caret overflow": func(f map[string]dbus.Variant) { wireNodes(f)[1]["caret"] = dbus.MakeVariant(uint64(math.MaxUint64)) },
"ranges shape": func(f map[string]dbus.Variant) { wireNodes(f)[1]["ranges"] = dbus.MakeVariant([]string{"bad"}) },
}
rangeCases := map[string]testWireRange{
"rune count": {0, 5, 0, 5, "four", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}},
"offset overflow": {math.MaxUint64, 0, 0, 1, "a", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}},
"utf16 span": {0, 1, 0, 1, "😀", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}},
"native offset overflow": {0, 1, math.MaxUint64, 0, "a", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}},
"clipped range": {0, 1, 0, 1, "a", atspi.Rectangle{X: -1, Y: 40, Width: 80, Height: 20}},
"mixed newline": {0, 2, 0, 2, "a\n", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}},
"line bound": {0, 4097, 0, 4097, strings.Repeat("a", 4097), atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}},
}
for name, r := range rangeCases {
tests[name] = func(f map[string]dbus.Variant) { wireNodes(f)[1]["ranges"] = dbus.MakeVariant([]testWireRange{r}) }
}
tests["node bound"] = func(f map[string]dbus.Variant) {
nodes := wireNodes(f)
for len(nodes) < 4097 {
nodes = append(nodes, reviewWireNode(fmt.Sprintf("node_%d", len(nodes)), "window", "panel", "", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}, false))
}
f["nodes"] = dbus.MakeVariant(nodes)
}
tests["range bound"] = func(f map[string]dbus.Variant) {
ranges := make([]testWireRange, 257)
for i := range ranges {
ranges[i] = testWireRange{uint64(i), uint64(i + 1), uint64(i), uint64(i + 1), "a", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}}
}
wireNodes(f)[1]["ranges"] = dbus.MakeVariant(ranges)
}
tests["control character bound"] = func(f map[string]dbus.Variant) {
ranges := make([]testWireRange, 17)
for i := range ranges {
s := uint64(i * 4096)
ranges[i] = testWireRange{s, s + 4096, s, s + 4096, strings.Repeat("a", 4096), atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}}
}
wireNodes(f)[1]["ranges"] = dbus.MakeVariant(ranges)
}
tests["depth bound"] = func(f map[string]dbus.Variant) {
nodes := wireNodes(f)
parent := "input"
for i := 0; i < 65; i++ {
id := strings.Repeat("d", i+1)
nodes = append(nodes, reviewWireNode(id, parent, "panel", "", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}, false))
parent = id
}
f["nodes"] = dbus.MakeVariant(nodes)
}
tests["window text bound"] = func(f map[string]dbus.Variant) {
nodes := wireNodes(f)
for n := 0; n < 17; n++ {
ranges := make([]testWireRange, 16)
for i := range ranges {
s := uint64(i * 4096)
ranges[i] = testWireRange{s, s + 4096, s, s + 4096, strings.Repeat("a", 4096), atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}}
}
nodes = append(nodes, reviewWireNode(fmt.Sprintf("large_%d", n), "window", "text", "", atspi.Rectangle{X: 20, Y: 40, Width: 200, Height: 30}, true, ranges...))
}
f["nodes"] = dbus.MakeVariant(nodes)
}
tests["serialized bound"] = func(f map[string]dbus.Variant) {
nodes := wireNodes(f)
for n := 0; n < 128; n++ {
ranges := make([]testWireRange, 256)
for i := range ranges {
s := uint64(i)
ranges[i] = testWireRange{s, s + 1, s, s + 1, "a", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}}
}
nodes = append(nodes, reviewWireNode(fmt.Sprintf("many_%d", n), "window", "text", "", atspi.Rectangle{X: 20, Y: 40, Width: 200, Height: 30}, true, ranges...))
}
f["nodes"] = dbus.MakeVariant(nodes)
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
fields := reviewWire()
mutate(fields)
snapshot, err := DecodeReviewSnapshot(fields)
if !errors.Is(err, ErrReviewInvalid) || len(snapshot.Nodes) != 0 {
t.Fatalf("invalid payload accepted: nodes=%d err=%v", len(snapshot.Nodes), err)
}
})
}
}
func TestReviewSnapshotActualBusStructs(t *testing.T) {
fields := reviewWire()
message := dbus.Message{Type: dbus.TypeMethodReply, Headers: map[dbus.HeaderField]dbus.Variant{
dbus.FieldReplySerial: dbus.MakeVariant(uint32(1)), dbus.FieldSignature: dbus.MakeVariant(dbus.SignatureOf(fields)),
}, Body: []any{fields}}
var wire bytes.Buffer
if err := message.EncodeTo(&wire, binary.LittleEndian); err != nil {
t.Fatal(err)
}
decoded, err := dbus.DecodeMessage(&wire)
if err != nil {
t.Fatal(err)
}
snapshot, err := DecodeReviewSnapshot(decoded.Body[0].(map[string]dbus.Variant))
if err != nil {
t.Fatal(err)
}
tree, focus, err := ApplyReviewSnapshot(nil, snapshot, ":1.20")
if err != nil {
t.Fatal(err)
}
ref, _ := tree.Reference(focus)
text, _ := tree.VisibleText(ref)
if text.Ranges[0].Text != "😀alpha beta" {
t.Fatalf("actual bus structs lost text: %+v", text)
}
}
func TestReviewSnapshotStatuses(t *testing.T) {
for status, want := range map[string]error{"unavailable": ErrReviewUnavailable, "stale": ErrReviewStale, "busy": ErrReviewBusy, "limited": ErrReviewLimited} {
t.Run(status, func(t *testing.T) {
fields := reviewWire()
fields["status"] = dbus.MakeVariant(status)
fields["nodes"] = dbus.MakeVariant([]map[string]dbus.Variant{})
snapshot, err := DecodeReviewSnapshot(fields)
if !errors.Is(err, want) || len(snapshot.Nodes) != 0 {
t.Fatalf("status %s: %v", status, err)
}
})
}
}