Files
2026-08-31 22:20:17 +02:00

1181 lines
34 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
)
const (
lugRepo = "starcitizen-lug/lug-helper"
wineRepo = "starcitizen-lug/lug-wine"
dxvkRepo = "doitsujin/dxvk"
)
type githubRelease struct {
TagName string `json:"tag_name"`
Assets []githubAsset `json:"assets"`
}
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Size int64 `json:"size"`
Digest string `json:"digest,omitempty"`
}
type componentMeta struct {
Version string `json:"version"`
Asset string `json:"asset,omitempty"`
URL string `json:"url,omitempty"`
Updated string `json:"updated"`
Fingerprint string `json:"fingerprint,omitempty"`
}
type wineRejection struct {
Version string `json:"version"`
Reason string `json:"reason"`
Fingerprint string `json:"fingerprint"`
Updated string `json:"updated"`
}
func (a *App) syncMaintenanceTimer() error {
cfg := a.loadConfig()
want := cfg.AutoMaintain || cfg.AutoApply
if !detectPlatform().SystemdUser {
if want {
return a.selfSync()
}
return nil
}
if want {
if err := a.installService(); err != nil {
return err
}
return run("", "systemctl", "--user", "enable", "--now", "citizen-launcher-maintenance.timer")
}
_ = run("", "systemctl", "--user", "disable", "--now", "citizen-launcher-maintenance.timer")
return nil
}
func (a *App) enableAutopilot() error {
cfg := a.loadConfig()
cfg.AutoMaintain = true
// The optional Omarchy integration may also be eligible for plugin updates.
st, _ := a.status(true)
if st.Managed == "omarchy-git" && !st.Dirty && st.Remote != "" {
cfg.AutoApply = true
if cfg.TrustedRemote == "" {
cfg.TrustedRemote = st.Remote
}
}
cfg.LastResult = "autopilot-enabled"
a.saveConfig(cfg)
if err := a.syncMaintenanceTimer(); err != nil {
return err
}
a.logf("autopilot enabled auto_integration=%v", cfg.AutoApply)
fmt.Println("enabled")
return nil
}
func (a *App) disableAutopilot() error {
cfg := a.loadConfig()
cfg.AutoMaintain = false
cfg.LastResult = "autopilot-disabled"
a.saveConfig(cfg)
if err := a.syncMaintenanceTimer(); err != nil {
return err
}
a.logf("autopilot disabled")
fmt.Println("disabled")
return nil
}
func (a *App) maintainGamingStack() error {
unlock, err := a.acquireMaintenanceLock()
if err != nil {
return err
}
defer unlock()
gc := a.loadGameConfig()
if prefixInitialized(gc.Prefix) && prefixBusy(gc.Prefix) {
a.logf("maintenance deferred: Wine prefix is active")
return nil
}
a.logf("maintenance begin")
var problems []string
// LUG is no longer synchronized during normal maintenance. It is only a
// lazy portable-toolbox fallback when distro tools are missing.
wineReady := true
if err := a.syncWineRunner(); err != nil {
problems = append(problems, "Wine runner: "+err.Error())
wineReady = false
}
// Keep the RSI PowerShell compatibility layer healthy without invoking
// Winetricks/MSI during background maintenance. The prefix-busy guard above
// ensures that no live RSI/game process is modified underneath the user.
if wineReady && prefixInitialized(gc.Prefix) && a.powerShellState(gc.Prefix) != "ready" {
env, _, envErr := a.runnerEnv(gc.Prefix)
if envErr != nil {
problems = append(problems, "RSI compatibility: "+envErr.Error())
} else if psErr := a.ensurePowerShell(gc, env); psErr != nil {
problems = append(problems, "RSI compatibility: "+psErr.Error())
}
}
if err := a.syncDXVK(); err != nil {
problems = append(problems, "DXVK: "+err.Error())
}
if err := a.syncRSILauncherIfNeeded(); err != nil {
problems = append(problems, "RSI Launcher: "+err.Error())
}
result := "ok"
if len(problems) > 0 {
result = strings.Join(problems, " | ")
}
a.updateConfig(func(c *Config) {
c.LastMaintenance = time.Now().Format(time.RFC3339)
c.MaintenanceResult = result
})
a.cleanupManagedState()
a.logf("maintenance end result=%s", result)
if len(problems) > 0 {
return errors.New(result)
}
return nil
}
func (a *App) syncLUGHelper() error {
release, err := githubLatest(lugRepo)
if err != nil {
return err
}
asset, err := selectAsset(release.Assets, func(n string) bool {
n = strings.ToLower(n)
return strings.HasSuffix(n, ".appimage") && strings.Contains(n, "lug-helper")
})
if err != nil {
return fmt.Errorf("release %s: %w", release.TagName, err)
}
dir := filepath.Join(a.vendorDir, "lug-helper")
metaPath := filepath.Join(dir, "meta.json")
target := filepath.Join(dir, "lug-helper.appimage")
if readMetaVersion(metaPath) == release.TagName {
if fi, err := os.Stat(target); err == nil && fi.Mode().IsRegular() {
return nil
}
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp := target + ".new"
if err := download(asset.BrowserDownloadURL, tmp); err != nil {
return err
}
if err := requireReleaseDigest(tmp, asset.Digest); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Chmod(tmp, 0o755); err != nil {
return err
}
if err := os.Rename(tmp, target); err != nil {
return err
}
if err := writeMeta(metaPath, componentMeta{
Version: release.TagName, Asset: asset.Name, URL: asset.BrowserDownloadURL,
Updated: time.Now().Format(time.RFC3339),
}); err != nil {
return err
}
a.logf("LUG Helper updated to %s asset=%s", release.TagName, asset.Name)
return nil
}
func (a *App) syncWineRunner() error {
releases, err := githubRecent(wineRepo, 10)
if err != nil {
return err
}
if len(releases) == 0 {
return errors.New("LUG Wine repository returned no releases")
}
base := filepath.Join(a.vendorDir, "wine")
metaPath := filepath.Join(base, "meta.json")
current := filepath.Join(base, "current")
rejectPath := filepath.Join(base, "rejected.json")
fingerprint := compatibilityFingerprint()
rejections := readWineRejections(rejectPath)
if err := os.MkdirAll(base, 0o755); err != nil {
return err
}
if err := os.MkdirAll(a.cacheDir, 0o755); err != nil {
return err
}
var failures []string
for _, release := range releases {
asset, assetErr := selectWineAsset(release.Assets)
if assetErr != nil {
continue
}
// If this exact immutable release already failed on the same hardware /
// libc environment, do not waste bandwidth testing it every six hours.
if rejection, ok := rejections[release.TagName]; ok && rejection.Fingerprint == fingerprint {
failures = append(failures, release.TagName+" cached-incompatible: "+rejection.Reason)
continue
}
// Re-test an already-active runner when the CPU/libc fingerprint changed.
// This catches an OS upgrade that invalidates a previously working runner.
if meta := readComponentMeta(metaPath); meta.Version == release.TagName {
if p, _ := filepath.EvalSymlinks(current); p != "" {
if _, err := os.Stat(filepath.Join(p, "bin", "wine")); err == nil {
if meta.Fingerprint == fingerprint {
a.activateRunnerForPrefix(p)
return nil
}
diag, testErr := a.selfTestRunnerDetailed(p)
if testErr == nil {
meta.Fingerprint = fingerprint
meta.Updated = time.Now().Format(time.RFC3339)
_ = writeMeta(metaPath, meta)
a.logf("active Wine runner revalidated: %s diagnostic=%s", release.TagName, compactDiagnostic(diag))
a.activateRunnerForPrefix(p)
return nil
}
reason := testErr.Error()
rejections[release.TagName] = wineRejection{Version: release.TagName, Reason: reason, Fingerprint: fingerprint, Updated: time.Now().Format(time.RFC3339)}
_ = writeWineRejections(rejectPath, rejections)
failures = append(failures, release.TagName+" no longer compatible: "+reason)
}
}
}
archive := filepath.Join(a.cacheDir, "wine-"+release.TagName+"-"+filepath.Base(asset.Name))
if err := download(asset.BrowserDownloadURL, archive); err != nil {
failures = append(failures, release.TagName+" download: "+err.Error())
continue
}
if err := requireReleaseDigest(archive, asset.Digest); err != nil {
_ = os.Remove(archive)
failures = append(failures, release.TagName+" checksum: "+err.Error())
continue
}
stage, err := os.MkdirTemp(a.cacheDir, "wine-stage-")
if err != nil {
return err
}
if extractErr := extractTarArchiveSafe(archive, stage); extractErr != nil {
os.RemoveAll(stage)
failures = append(failures, release.TagName+" extract: "+extractErr.Error())
continue
}
runnerRoot, err := findRootContaining(stage, filepath.Join("bin", "wine"))
if err != nil {
os.RemoveAll(stage)
failures = append(failures, release.TagName+" layout: "+err.Error())
continue
}
diag, testErr := a.selfTestRunnerDetailed(runnerRoot)
if testErr != nil {
reason := testErr.Error()
a.logf("Wine candidate %s rejected: %s | diagnostic=%s", release.TagName, reason, compactDiagnostic(diag))
rejections[release.TagName] = wineRejection{
Version: release.TagName, Reason: reason, Fingerprint: fingerprint,
Updated: time.Now().Format(time.RFC3339),
}
_ = writeWineRejections(rejectPath, rejections)
failures = append(failures, release.TagName+" incompatible: "+reason)
os.RemoveAll(stage)
_ = os.Remove(archive)
continue
}
target := filepath.Join(base, release.TagName)
_ = os.RemoveAll(target + ".new")
if err := copyTree(runnerRoot, target+".new"); err != nil {
os.RemoveAll(stage)
return err
}
_ = os.RemoveAll(target)
if err := os.Rename(target+".new", target); err != nil {
os.RemoveAll(stage)
return err
}
// Preserve one known-previous runner for diagnostics/manual rollback.
if oldCurrent, err := filepath.EvalSymlinks(current); err == nil && oldCurrent != "" && oldCurrent != target {
previous := filepath.Join(base, "previous")
tmpPrevious := previous + ".new"
_ = os.Remove(tmpPrevious)
if os.Symlink(oldCurrent, tmpPrevious) == nil {
_ = os.Rename(tmpPrevious, previous)
}
}
tmpLink := current + ".new"
_ = os.Remove(tmpLink)
if err := os.Symlink(target, tmpLink); err != nil {
os.RemoveAll(stage)
return err
}
if err := os.Rename(tmpLink, current); err != nil {
os.RemoveAll(stage)
return err
}
if err := writeMeta(metaPath, componentMeta{
Version: release.TagName, Asset: asset.Name, URL: asset.BrowserDownloadURL,
Updated: time.Now().Format(time.RFC3339), Fingerprint: fingerprint,
}); err != nil {
os.RemoveAll(stage)
return err
}
delete(rejections, release.TagName)
_ = writeWineRejections(rejectPath, rejections)
a.activateRunnerForPrefix(target)
a.logf("Wine runner activated: %s asset=%s diagnostic=%s", release.TagName, asset.Name, compactDiagnostic(diag))
os.RemoveAll(stage)
_ = os.Remove(archive)
return nil
}
// A previously validated runner may have fallen out of the recent-release
// window. Never strand a working installation just because newer candidates
// are incompatible with this CPU/libc combination.
if p, err := filepath.EvalSymlinks(current); err == nil && p != "" {
if diag, testErr := a.selfTestRunnerDetailed(p); testErr == nil {
meta := readComponentMeta(metaPath)
meta.Fingerprint = fingerprint
meta.Updated = time.Now().Format(time.RFC3339)
_ = writeMeta(metaPath, meta)
a.logf("no newer compatible Wine runner; keeping %s diagnostic=%s", meta.Version, compactDiagnostic(diag))
return nil
}
}
if len(failures) == 0 {
return errors.New("no stable LUG Wine asset found in recent releases")
}
return fmt.Errorf("no compatible stable LUG Wine runner found; %s", strings.Join(failures, " | "))
}
func (a *App) selfTestRunner(runner string) error {
_, err := a.selfTestRunnerDetailed(runner)
return err
}
func (a *App) selfTestRunnerDetailed(runner string) (string, error) {
bin := filepath.Join(runner, "bin")
wine := filepath.Join(bin, "wine")
wineboot := filepath.Join(bin, "wineboot")
wineserver := filepath.Join(bin, "wineserver")
for _, required := range []string{wine, wineboot, wineserver} {
fi, err := os.Stat(required)
if err != nil {
return "", fmt.Errorf("runner is incomplete: %s missing", filepath.Base(required))
}
if fi.Mode()&0o111 == 0 {
return "", fmt.Errorf("runner is incomplete: %s is not executable", filepath.Base(required))
}
}
tmp, err := os.MkdirTemp(a.cacheDir, "wine-selftest-prefix-")
if err != nil {
return "", err
}
defer os.RemoveAll(tmp)
env := environmentWithout(os.Environ(), "SDL_VIDEODRIVER", "WINE", "WINESERVER", "WINEPREFIX", "WINEARCH", "WINEDEBUG", "WINEDLLOVERRIDES")
env = append(env,
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"WINE="+wine,
"WINEPREFIX="+tmp,
"WINEARCH=win64",
"WINEDEBUG=-all",
"WINEDLLOVERRIDES=winemenubuilder.exe=d",
)
var diagnostic []string
// Capture the runner's own version before prefix creation. This also catches
// loader/CPU failures that otherwise produce an empty wineboot stdout.
versionCmd := exec.Command(wine, "--version")
versionCmd.Env = env
versionOut, versionErr := versionCmd.CombinedOutput()
if versionErr != nil {
return strings.Join(diagnostic, "\n"), fmt.Errorf(
"wine --version failed: %s", formatCommandFailure(versionErr, versionOut))
}
diagnostic = append(diagnostic, "wine="+strings.TrimSpace(string(versionOut)))
// Use the runner-provided wineboot directly, matching how regular Wine
// launchers create a prefix. Never fall back to `wine wineboot`.
bootCmd := exec.Command(wineboot, "-u")
bootCmd.Env = env
bootOut, bootErr := bootCmd.CombinedOutput()
if bootErr != nil {
return strings.Join(diagnostic, "\n"), fmt.Errorf(
"wineboot failed: %s", formatCommandFailure(bootErr, bootOut))
}
if text := strings.TrimSpace(string(bootOut)); text != "" {
diagnostic = append(diagnostic, "wineboot="+text)
}
// Let wineserver finish pending registry writes before validating the prefix.
waitCmd := exec.Command(wineserver, "-w")
waitCmd.Env = env
waitOut, waitErr := waitCmd.CombinedOutput()
if waitErr != nil {
return strings.Join(diagnostic, "\n"), fmt.Errorf(
"wineserver -w failed: %s", formatCommandFailure(waitErr, waitOut))
}
deadline := time.Now().Add(20 * time.Second)
for {
ready := true
for _, p := range []string{"drive_c", "system.reg", "user.reg"} {
if _, err := os.Stat(filepath.Join(tmp, p)); err != nil {
ready = false
break
}
}
if ready {
break
}
if time.Now().After(deadline) {
return strings.Join(diagnostic, "\n"),
errors.New("wineboot returned successfully but did not create drive_c/system.reg/user.reg")
}
time.Sleep(250 * time.Millisecond)
}
cmd := exec.Command(wine, "cmd.exe", "/c", "echo", "%APPDATA%")
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return strings.Join(diagnostic, "\n"), fmt.Errorf(
"APPDATA probe failed: %s", formatCommandFailure(err, out))
}
value := strings.TrimSpace(string(out))
diagnostic = append(diagnostic, "APPDATA="+value)
if value == "" || strings.Contains(value, "%APPDATA%") {
return strings.Join(diagnostic, "\n"), errors.New("Wine returned no usable APPDATA path")
}
killCmd := exec.Command(wineserver, "-k")
killCmd.Env = env
_, _ = killCmd.CombinedOutput()
return strings.Join(diagnostic, "\n"), nil
}
func formatCommandFailure(err error, out []byte) string {
text := strings.TrimSpace(string(out))
if len(text) > 1200 {
// Keep status output compact but preserve enough detail in updater.log.
text = text[len(text)-1200:]
}
if err == nil {
if text == "" {
return "command produced no expected result"
}
return text
}
if text == "" {
return err.Error()
}
return err.Error() + ": " + text
}
func compactDiagnostic(s string) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > 600 {
return s[:600] + "…"
}
return s
}
func (a *App) wineDoctor() error {
lock, err := a.stackOperationLock(2 * time.Second)
if err != nil {
return err
}
defer releaseFileLock(lock)
current := filepath.Join(a.vendorDir, "wine", "current")
root, err := filepath.EvalSymlinks(current)
if err != nil {
return errors.New("managed Wine runner is not installed yet")
}
fmt.Println("runner=" + root)
if err := a.selfTestRunner(root); err != nil {
fmt.Println("wine_selftest=failed")
return err
}
fmt.Println("wine_selftest=ok")
return nil
}
func (a *App) activateRunnerForPrefix(runner string) {
prefix := a.configuredPrefix()
if prefix == "" || !prefixInitialized(prefix) || prefixBusy(prefix) {
return
}
launch := filepath.Join(prefix, "sc-launch.sh")
data, err := os.ReadFile(launch)
if err != nil {
return
}
want := `export wine_path="` + filepath.Join(runner, "bin") + `"`
lines := strings.Split(string(data), "\n")
found := false
for i, line := range lines {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "export wine_path=") {
lines[i] = want
found = true
break
}
}
if !found {
return
}
tmp := launch + ".new"
if os.WriteFile(tmp, []byte(strings.Join(lines, "\n")), 0o755) == nil {
_ = os.Rename(tmp, launch)
a.logf("launch script switched to managed Wine %s", runner)
}
}
var dxvkRequiredDLLs = []string{"d3d8.dll", "d3d9.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll"}
var dxvkOverrideNames = []string{"d3d8", "d3d9", "d3d10core", "d3d11", "dxgi"}
func (a *App) dxvkMarkerPath() string { return filepath.Join(a.vendorDir, "dxvk", "overrides.txt") }
func (a *App) dxvkDLLsReady(prefix string) bool {
if !prefixInitialized(prefix) {
return false
}
system32 := filepath.Join(prefix, "drive_c", "windows", "system32")
for _, name := range dxvkRequiredDLLs {
fi, err := os.Stat(filepath.Join(system32, name))
if err != nil || fi.Size() < 1024 {
return false
}
}
return true
}
func (a *App) dxvkState(prefix, version string) string {
if !prefixInitialized(prefix) || version == "" {
return "missing"
}
if !a.dxvkDLLsReady(prefix) {
return "repair"
}
marker := readText(a.dxvkMarkerPath())
want := version + "\n" + prefix
if marker != want {
return "repair"
}
return "ready"
}
func (a *App) ensureDXVKOverrides(prefix, version string) error {
if prefixBusy(prefix) {
return errors.New("DXVK kann nicht geändert werden, solange der Wine-Prefix verwendet wird")
}
env, runner, err := a.runnerEnv(prefix)
if err != nil {
return err
}
wine := filepath.Join(runner, "bin", "wine")
const key = `HKEY_CURRENT_USER\Software\Wine\DllOverrides`
for _, dll := range dxvkOverrideNames {
cmd := exec.Command(wine, "reg", "add", key, "/v", dll, "/t", "REG_SZ", "/d", "native", "/f")
cmd.Env = env
out, runErr := cmd.CombinedOutput()
if runErr != nil {
return fmt.Errorf("DXVK DLL-Override %s: %s", dll, formatCommandFailure(runErr, out))
}
}
wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w")
wait.Env = env
if out, runErr := wait.CombinedOutput(); runErr != nil {
return fmt.Errorf("DXVK Registry speichern: %s", formatCommandFailure(runErr, out))
}
return atomicWriteFile(a.dxvkMarkerPath(), []byte(version+"\n"+prefix+"\n"), 0o600)
}
func (a *App) syncDXVK() error {
prefix := a.configuredPrefix()
if prefix == "" || !prefixInitialized(prefix) {
return nil
}
if prefixBusy(prefix) {
return errors.New("DXVK-Aktualisierung pausiert: Wine-Prefix ist aktiv")
}
release, err := githubLatest(dxvkRepo)
if err != nil {
return err
}
asset, err := selectAsset(release.Assets, func(n string) bool {
n = strings.ToLower(n)
return strings.HasPrefix(n, "dxvk-") && strings.HasSuffix(n, ".tar.gz")
})
if err != nil {
return err
}
base := filepath.Join(a.vendorDir, "dxvk")
metaPath := filepath.Join(base, "meta.json")
currentVersion := readMetaVersion(metaPath)
if currentVersion == release.TagName && a.dxvkDLLsReady(prefix) {
if err := a.ensureDXVKOverrides(prefix, release.TagName); err != nil {
return err
}
return nil
}
if err := os.MkdirAll(base, 0o755); err != nil {
return err
}
if err := os.MkdirAll(a.cacheDir, 0o755); err != nil {
return err
}
archive := filepath.Join(a.cacheDir, filepath.Base(asset.Name))
if err := download(asset.BrowserDownloadURL, archive); err != nil {
return err
}
if err := requireReleaseDigest(archive, asset.Digest); err != nil {
_ = os.Remove(archive)
return err
}
stage, err := os.MkdirTemp(a.cacheDir, "dxvk-stage-")
if err != nil {
return err
}
defer os.RemoveAll(stage)
if err := extractTarArchiveSafe(archive, stage); err != nil {
return fmt.Errorf("DXVK entpacken: %w", err)
}
root, err := findRootContaining(stage, filepath.Join("x64", "dxgi.dll"))
if err != nil {
return err
}
system32 := filepath.Join(prefix, "drive_c", "windows", "system32")
syswow64 := filepath.Join(prefix, "drive_c", "windows", "syswow64")
backup := filepath.Join(a.stateDir, "dxvk-backup-"+time.Now().Format("20060102-150405"))
_ = os.MkdirAll(backup, 0o700)
if err := installDLLDir(filepath.Join(root, "x64"), system32, backup); err != nil {
return err
}
if _, err := os.Stat(filepath.Join(root, "x32")); err == nil {
if err := installDLLDir(filepath.Join(root, "x32"), syswow64, backup); err != nil {
return err
}
}
if !a.dxvkDLLsReady(prefix) {
return errors.New("DXVK-Installation unvollständig: benötigte x64-DLLs fehlen")
}
if err := a.ensureDXVKOverrides(prefix, release.TagName); err != nil {
return err
}
if err := writeMeta(metaPath, componentMeta{
Version: release.TagName, Asset: asset.Name, URL: asset.BrowserDownloadURL,
Updated: time.Now().Format(time.RFC3339),
}); err != nil {
return err
}
a.logf("DXVK updated to %s and native DLL overrides activated", release.TagName)
_ = os.Remove(archive)
return nil
}
func installDLLDir(srcDir, dstDir, backup string) error {
if err := os.MkdirAll(dstDir, 0o755); err != nil {
return err
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".dll") {
continue
}
src := filepath.Join(srcDir, e.Name())
dst := filepath.Join(dstDir, e.Name())
if _, err := os.Stat(dst); err == nil {
_ = copyFile(dst, filepath.Join(backup, filepath.Base(dstDir)+"-"+e.Name()), 0o644)
}
tmp := dst + ".new"
if err := copyFile(src, tmp, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, dst); err != nil {
return err
}
}
return nil
}
func (a *App) configuredPrefix() string {
return a.loadGameConfig().Prefix
}
func prefixInitialized(prefix string) bool {
for _, p := range []string{"drive_c", "system.reg", "user.reg"} {
if _, err := os.Stat(filepath.Join(prefix, p)); err != nil {
return false
}
}
return true
}
func prefixBusy(prefix string) bool {
proc, err := os.ReadDir("/proc")
if err != nil {
return false
}
// Wine keeps a few infrastructure processes around briefly even when no
// user-facing program is active. Those are safe to terminate/restart while
// holding the stack lock and must not permanently block maintenance.
infrastructure := []string{
"wineserver", "services.exe", "winedevice.exe", "explorer.exe",
"plugplay.exe", "rpcss.exe", "svchost.exe", "conhost.exe",
}
for _, e := range proc {
if !e.IsDir() {
continue
}
name := e.Name()
if name == "" || name[0] < '0' || name[0] > '9' {
continue
}
data, err := os.ReadFile(filepath.Join("/proc", name, "environ"))
if err != nil || !processHasEnvValue(data, "WINEPREFIX", prefix) {
continue
}
cmdline, _ := os.ReadFile(filepath.Join("/proc", name, "cmdline"))
cmd := strings.ToLower(strings.ReplaceAll(string(cmdline), "\x00", " "))
ignored := false
for _, infra := range infrastructure {
if strings.Contains(cmd, infra) {
ignored = true
break
}
}
if !ignored {
return true
}
}
return false
}
func githubRecent(repo string, limit int) ([]githubRelease, error) {
if limit <= 0 {
limit = 10
}
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d", repo, limit)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub %s returned HTTP %d", repo, resp.StatusCode)
}
var releases []githubRelease
if err := json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(&releases); err != nil {
return nil, err
}
return releases, nil
}
func compatibilityFingerprint() string {
var parts []string
if out, err := exec.Command("getconf", "GNU_LIBC_VERSION").CombinedOutput(); err == nil {
parts = append(parts, strings.TrimSpace(string(out)))
}
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(strings.ToLower(line), "flags") {
parts = append(parts, strings.TrimSpace(line))
break
}
}
}
if out, err := exec.Command("uname", "-m").CombinedOutput(); err == nil {
parts = append(parts, strings.TrimSpace(string(out)))
}
return strings.Join(parts, " | ")
}
func readWineRejections(path string) map[string]wineRejection {
result := map[string]wineRejection{}
data, err := os.ReadFile(path)
if err != nil {
return result
}
_ = json.Unmarshal(data, &result)
return result
}
func writeWineRejections(path string, values map[string]wineRejection) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(values, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
tmp := path + ".new"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
func githubLatest(repo string) (githubRelease, error) {
var rel githubRelease
req, err := http.NewRequest("GET", "https://api.github.com/repos/"+repo+"/releases/latest", nil)
if err != nil {
return rel, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return rel, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return rel, fmt.Errorf("GitHub %s returned HTTP %d", repo, resp.StatusCode)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&rel); err != nil {
return rel, err
}
if rel.TagName == "" {
return rel, errors.New("release has no tag")
}
return rel, nil
}
func selectWineAsset(assets []githubAsset) (githubAsset, error) {
valid := func(n string) bool {
n = strings.ToLower(n)
if strings.Contains(n, "staging") || strings.Contains(n, "checksum") ||
strings.Contains(n, "sha") || strings.Contains(n, "experimental") ||
strings.Contains(n, "wayland") || !strings.Contains(n, "lug-wine-tkg-git") {
return false
}
return strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") ||
strings.HasSuffix(n, ".tar.xz") || strings.HasSuffix(n, ".tar.zst")
}
// Prefer gzip: it is decoded entirely in-process and therefore works on all
// supported distributions without another decompressor. XZ/Zstd remain
// compatible fallbacks for future upstream packaging changes.
for _, suffix := range []string{".tar.gz", ".tgz", ".tar.xz", ".tar.zst"} {
asset, err := selectAsset(assets, func(n string) bool {
return valid(n) && strings.HasSuffix(strings.ToLower(n), suffix)
})
if err == nil {
return asset, nil
}
}
return githubAsset{}, errors.New("no stable LUG Wine archive found")
}
func selectAsset(assets []githubAsset, match func(string) bool) (githubAsset, error) {
var candidates []githubAsset
for _, a := range assets {
if match(a.Name) {
candidates = append(candidates, a)
}
}
if len(candidates) == 0 {
return githubAsset{}, errors.New("no matching release asset")
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name < candidates[j].Name })
return candidates[0], nil
}
func download(url, target string) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 20 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
_, copyErr := io.Copy(f, resp.Body)
closeErr := f.Close()
if copyErr != nil {
_ = os.Remove(target)
return copyErr
}
return closeErr
}
func findRootContaining(base, relative string) (string, error) {
var matches []string
err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() && d.Name() == filepath.Base(filepath.Dir(relative)) {
candidate := filepath.Join(filepath.Dir(path), relative)
if _, err := os.Stat(candidate); err == nil {
matches = append(matches, filepath.Dir(path))
}
}
return nil
})
if err != nil {
return "", err
}
if len(matches) == 0 {
// Robust fallback: inspect every directory for the relative path.
_ = filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
if err == nil && d.IsDir() {
if _, e := os.Stat(filepath.Join(path, relative)); e == nil {
matches = append(matches, path)
}
}
return nil
})
}
if len(matches) == 0 {
return "", fmt.Errorf("archive does not contain %s", relative)
}
sort.Strings(matches)
return matches[0], nil
}
func copyTree(src, dst string) error {
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
info, err := d.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
link, err := os.Readlink(path)
if err != nil {
return err
}
return os.Symlink(link, target)
}
mode := info.Mode().Perm()
if mode == 0 {
mode = 0o644
}
return copyFile(path, target, mode)
})
}
func readComponentMeta(path string) componentMeta {
data, err := os.ReadFile(path)
if err != nil {
return componentMeta{}
}
var meta componentMeta
if json.Unmarshal(data, &meta) != nil {
return componentMeta{}
}
return meta
}
func readMetaVersion(path string) string { return readComponentMeta(path).Version }
func writeMeta(path string, meta componentMeta) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, _ := json.MarshalIndent(meta, "", " ")
data = append(data, '\n')
return atomicWriteFile(path, data, 0o644)
}
func (a *App) saveConfig(cfg Config) {
_ = os.MkdirAll(a.configDir, 0o755)
data, _ := json.MarshalIndent(cfg, "", " ")
data = append(data, '\n')
_ = atomicWriteFile(a.configPath, data, 0o600)
}
func (a *App) cleanupManagedState() {
// Keep the active and previous Wine runners; old extracted runners can be
// large and are safe to re-download if ever needed again.
wineBase := filepath.Join(a.vendorDir, "wine")
keep := map[string]bool{}
for _, link := range []string{"current", "previous"} {
if p, err := filepath.EvalSymlinks(filepath.Join(wineBase, link)); err == nil && p != "" {
keep[filepath.Clean(p)] = true
}
}
if entries, err := os.ReadDir(wineBase); err == nil {
for _, e := range entries {
if !e.IsDir() || strings.HasSuffix(e.Name(), ".new") {
continue
}
p := filepath.Join(wineBase, e.Name())
if !keep[filepath.Clean(p)] {
_ = os.RemoveAll(p)
}
}
}
pruneNamedDirectories(filepath.Join(a.vendorDir, "winetricks"), 2, "")
pruneNamedDirectories(a.stateDir, 3, "dxvk-backup-")
pruneOldCache(a.cacheDir, 30*24*time.Hour)
}
func pruneNamedDirectories(base string, keep int, prefix string) {
entries, err := os.ReadDir(base)
if err != nil {
return
}
type item struct {
path string
mod time.Time
}
var items []item
for _, e := range entries {
if !e.IsDir() || (prefix != "" && !strings.HasPrefix(e.Name(), prefix)) {
continue
}
if info, err := e.Info(); err == nil {
items = append(items, item{filepath.Join(base, e.Name()), info.ModTime()})
}
}
sort.Slice(items, func(i, j int) bool { return items[i].mod.After(items[j].mod) })
for i := keep; i < len(items); i++ {
_ = os.RemoveAll(items[i].path)
}
}
func pruneOldCache(base string, age time.Duration) {
entries, err := os.ReadDir(base)
if err != nil {
return
}
cutoff := time.Now().Add(-age)
for _, e := range entries {
info, err := e.Info()
if err == nil && info.ModTime().Before(cutoff) {
_ = os.RemoveAll(filepath.Join(base, e.Name()))
}
}
}
func (a *App) acquireMaintenanceLock() (func(), error) {
if err := os.MkdirAll(a.stateDir, 0o755); err != nil {
return nil, err
}
path := filepath.Join(a.stateDir, "maintenance.lock")
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
return nil, errors.New("another Citizen Launcher maintenance run is active")
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
}, nil
}
func verifyReleaseDigest(path, digest string) error {
if digest == "" {
return nil
}
parts := strings.SplitN(digest, ":", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "sha256" {
return nil
}
want := strings.ToLower(strings.TrimSpace(parts[1]))
got, err := fileHash(path)
if err != nil {
return err
}
if strings.ToLower(got) != want {
return fmt.Errorf("SHA-256 mismatch for %s", filepath.Base(path))
}
return nil
}
// Executable gaming-stack components fail closed when GitHub does not provide
// a SHA-256 digest. An upstream metadata outage is recoverable; activating an
// unverifiable Wine/DXVK/toolbox download is not.
func requireReleaseDigest(path, digest string) error {
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(digest)), "sha256:") {
return fmt.Errorf("GitHub asset %s has no SHA-256 digest; refusing unverified component", filepath.Base(path))
}
return verifyReleaseDigest(path, digest)
}