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
|
||||
}
|
||||
Reference in new issue
Block a user