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
}