Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user