338 lines
8.4 KiB
Go
338 lines
8.4 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"
|
|
"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 {
|
|
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) 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
|
|
}
|