514 lines
15 KiB
Go
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
|
|
}
|