// 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<