Files
2026-11-02 23:37:45 -05:00

514 lines
15 KiB
Go

// Package wine supervises the optional installed Wine Access 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"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
const managedKey = "CTHULHU_WINE_ACCESS_MANAGED"
type prefix struct {
path, loader string
environment map[string]string
programs map[string]bool
}
type process struct {
cmd *exec.Cmd
done chan struct{}
started time.Time
seen time.Time
failures int
next time.Time
stopping atomic.Bool
controllerOnly bool
// launchedHelper and exclusions describe the most recent launch and
// are written only by the poll goroutine. The ready fields are written
// by the stderr copier for the launch generation that armed them
// and read by the poll goroutine.
launchedHelper string
exclusions map[string]bool
generation atomic.Uint64
readySeen atomic.Bool
readyExclusions atomic.Int64
}
// Settings is one snapshot of the reader's Wine values. The manager never
// keeps its own copy across polls: each poll and helper spawn works from a
// fresh snapshot, so settings applied after startup take effect with no
// reader restart.
type Settings struct {
ControllerOnly bool
ExcludedPrograms []string
}
type Manager struct {
logger *slog.Logger
helper string
procRoot string
cancel context.CancelFunc
done chan struct{}
// SettingsSource supplies the live Wine settings. It is installed
// before Start and read only by the poll goroutine, once per poll;
// spawns made by that poll use that same snapshot. A nil source
// yields empty settings.
SettingsSource func() Settings
// unconfirmed remembers helper binaries whose ready record did not
// confirm the exclusions sent, keyed by launched executable path.
// Later spawns of such a binary go wholesale directly.
unconfirmedMu sync.Mutex
unconfirmed map[string]bool
}
func (manager *Manager) helper_unconfirmed(helper string) bool {
manager.unconfirmedMu.Lock()
defer manager.unconfirmedMu.Unlock()
return manager.unconfirmed[helper]
}
func (manager *Manager) mark_unconfirmed(helper string) {
manager.unconfirmedMu.Lock()
defer manager.unconfirmedMu.Unlock()
if manager.unconfirmed == nil {
manager.unconfirmed = make(map[string]bool)
}
manager.unconfirmed[helper] = true
}
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 {
return findHelper([]string{
filepath.Join(os.Getenv("HOME"), ".local", "libexec", "wine-access", "wine-access.exe"),
filepath.Join(os.Getenv("HOME"), ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe"),
"/usr/local/libexec/wine-access/wine-access.exe",
"/usr/libexec/wine-access/wine-access.exe",
"/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe",
})
}
func findHelper(candidates []string) string {
if path := os.Getenv("CTHULHU_WINE_ACCESS_HELPER"); executable(path) {
return path
}
for _, path := range candidates {
if executable(path) {
return path
}
}
for _, name := range []string{"wine-access.exe", "cthulhu-wine-access.exe"} {
if path, err := exec.LookPath(name); 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) current_settings() Settings {
if manager.SettingsSource == nil {
return Settings{}
}
return manager.SettingsSource()
}
func (manager *Manager) poll(active map[string]*process) {
now := time.Now()
// Read the live settings once per poll. Restart decisions and every
// spawn in this poll use this snapshot, so a value applied between
// polls governs the next poll and spawn without a reader restart.
settings := manager.current_settings()
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:
if manager.restart_running_helper(child, source, settings) {
stop(child)
} else {
continue
}
}
}
if now.Before(child.next) {
continue
}
if err := manager.launch(child, source, now, settings); 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)
}
}
}
// restart_running_helper decides whether a running helper must restart to
// gain protection it lacks. Restarts only ever add protection: a wholesale
// helper never restarts here, and removing a listing or clearing the
// global switch never downgrades a running helper.
func (manager *Manager) restart_running_helper(child *process, source prefix, settings Settings) bool {
if child.controllerOnly {
return false
}
if wholesale_controller_only(settings) || manager.helper_unconfirmed(child.launchedHelper) {
return true
}
if child.readySeen.Load() && int(child.readyExclusions.Load()) != len(child.exclusions) {
manager.mark_unconfirmed(child.launchedHelper)
manager.logger.Warn("Wine accessibility helper did not confirm hook exclusions; restarting without dialog reading",
"sent", len(child.exclusions), "reported", child.readyExclusions.Load())
return true
}
// A newly listed program that is currently discovered but lacks its
// flag restarts the helper once, in the same mode, now carrying it.
// Listings for programs that are not running wait for the next spawn.
for _, name := range exclusion_names(settings) {
if source.programs[name] && !child.exclusions[name] {
return true
}
}
return false
}
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, settings Settings) error {
helper, err := stage_helper(manager.helper, source.environment)
if err != nil {
return err
}
controllerOnly := child.controllerOnly || wholesale_controller_only(settings) ||
manager.helper_unconfirmed(helper)
arguments := helper_arguments(controllerOnly, settings)
cmd := exec.Command(helper, arguments...)
diagnostics := newHelperDiagnostics()
generation := child.generation.Add(1)
child.readySeen.Store(false)
child.readyExclusions.Store(0)
diagnostics.onReady = func(ready helperReady) {
if child.generation.Load() != generation {
return
}
child.readyExclusions.Store(int64(ready.HookExclusions))
child.readySeen.Store(true)
}
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = diagnostics
// Bound stderr draining if a Wine descendant retains the inherited pipe.
cmd.WaitDelay = 200 * time.Millisecond
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
environment := make([]string, 0, len(os.Environ())+8)
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if key != "WINEESYNC" && key != "WINEFSYNC" {
environment = append(environment, entry)
}
}
// Hooks need the application's display, but D-Bus requests go to this
// reader's session. Applications launched from another session bus must
// not redirect the helper away from the service supervising it.
for _, key := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "WINEESYNC", "WINEFSYNC"} {
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
child.stopping.Store(false)
child.controllerOnly = controllerOnly
child.launchedHelper = helper
child.exclusions = make(map[string]bool)
for _, name := range exclusion_names(settings) {
child.exclusions[name] = true
}
logger := manager.logger.With("helper_pid", cmd.Process.Pid)
// Mirror the other reader's helper-start evidence: sorted discovered
// program names and the exact extra helper arguments.
programs := make([]string, 0, len(source.programs))
for name := range source.programs {
programs = append(programs, name)
}
sort.Strings(programs)
logger.Info("Wine accessibility helper started",
"programs", mirroredStringList(programs), "extra", mirroredStringList(arguments))
logger.Debug("Wine accessibility helper launch configuration", "executable", cmd.Path,
"wine_loader", source.loader, "wine_prefix", source.path)
go diagnostics.logRecords(logger)
go manager.wait(cmd, child, diagnostics, logger)
return nil
}
// mirroredStringList formats values as the other reader's spawn log does
// (a Python list of repr strings), so both readers' helper-start records
// can be compared directly.
func mirroredStringList(values []string) string {
parts := make([]string, len(values))
for index, value := range values {
parts[index] = mirroredString(value)
}
return "[" + strings.Join(parts, ", ") + "]"
}
func mirroredString(value string) string {
quote := "'"
if strings.Contains(value, "'") && !strings.Contains(value, `"`) {
quote = `"`
}
var builder strings.Builder
builder.WriteString(quote)
for _, char := range value {
if char == '\\' || string(char) == quote {
builder.WriteByte('\\')
}
builder.WriteRune(char)
}
builder.WriteString(quote)
return builder.String()
}
func (manager *Manager) wait(cmd *exec.Cmd, child *process, diagnostics *helperDiagnostics, logger *slog.Logger) {
err := cmd.Wait()
diagnostics.finish()
// Preserve record order at normal exit without letting a slow log sink
// hold process supervision indefinitely.
select {
case <-diagnostics.done:
case <-time.After(100 * time.Millisecond):
}
if err != nil && !child.stopping.Load() {
logger.Warn("Wine accessibility helper exited", "error", err)
} else {
logger.Info("Wine accessibility helper exited", "requested_stop", child.stopping.Load())
}
close(child.done)
}
func stop(child *process) {
if child.cmd == nil {
return
}
select {
case <-child.done:
child.cmd = nil
return
default:
}
child.stopping.Store(true)
_ = 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 := wine_loader(exe, environment["WINELOADER"])
programs := map[string]bool{program_name(string(commandLine)): true}
if previous, ok := result[path]; ok {
previous.programs[program_name(string(commandLine))] = true
continue
}
result[path] = prefix{path: path, loader: loader, environment: environment, programs: programs}
}
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
}
normalized := strings.ReplaceAll(program, "\\", "/")
if strings.HasSuffix(normalized, "/windows/system32/steam.exe") || strings.HasSuffix(normalized, "/windows/syswow64/steam.exe") {
return false
}
program = filepath.Base(normalized)
switch program {
case "", "xalia.exe", "conhost.exe", "tabtip.exe", "start.exe", "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
}