Files
omarchy-sc/backend/cmd/citizen-launcher/stack.go
T
2026-08-31 22:20:17 +02:00

1450 lines
46 KiB
Go

package main
import (
"archive/zip"
"bufio"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
const (
winetricksVersion = "20260125"
winetricksSHA256 = "431f82fc74000e6c864409f1d8fb495d696c03928808e3e8acffc45179312a7b"
rsiBaseURL = "https://install.robertsspaceindustries.com/rel/2"
rsiLatestYML = rsiBaseURL + "/latest.yml"
)
// Keep Winetricks focused on small, proven prefix tweaks. PowerShell is managed
// separately below because the MSI-based Winetricks path has failed on otherwise
// healthy modern Wine builds. We install the official portable PowerShell Core
// ZIP plus the RSI-compatible PowerShell wrapper instead.
var basePrefixWinetricksVerbs = []string{"arial", "tahoma", "win11"}
const (
wineFileAssociationsKey = "HKEY_CURRENT_USER\\Software\\Wine\\FileOpenAssociations"
powerShellCoreVersion = "7.4.19"
powerShellCoreURL = "https://github.com/PowerShell/PowerShell/releases/download/v7.4.19/PowerShell-7.4.19-win-x64.zip"
powerShellCoreSHA256 = "cd62ad6d8174cc6fb85b335a0058444bc934fe27c39fa97fe342134286d28af9"
powerShellWrapperVersion = "3.0.5"
powerShellWrapperURL = "https://github.com/ProjectSynchro/powershell-wrapper-for-wine/releases/download/v3.0.5/powershell-wrapper.zip"
powerShellWrapperSHA256 = "08f866265e0395f4bc5ddb18f3dff345771d039a62911e506c62084fd2533ec3"
)
type GameConfig struct {
Prefix string `json:"prefix"`
GameDir string `json:"game_dir"`
LauncherEXE string `json:"launcher_exe"`
RSIInstaller string `json:"rsi_installer,omitempty"`
WinetricksVersion string `json:"winetricks_version,omitempty"`
InstalledAt string `json:"installed_at,omitempty"`
LastRepair string `json:"last_repair,omitempty"`
}
type GameStatus struct {
BackendVersion string `json:"backend_version"`
Health string `json:"health"`
Hardware string `json:"hardware"`
HardwareReason string `json:"hardware_reason,omitempty"`
GPU string `json:"gpu,omitempty"`
Vulkan string `json:"vulkan,omitempty"`
CPUAVX bool `json:"cpu_avx"`
RAMGiB int `json:"ram_gib"`
CombinedGiB int `json:"combined_gib"`
DiskFreeGiB int `json:"disk_free_gib"`
Prefix string `json:"prefix"`
PrefixState string `json:"prefix_state"`
LauncherState string `json:"launcher_state"`
GameState string `json:"game_state"`
WineVersion string `json:"wine_version,omitempty"`
DXVKVersion string `json:"dxvk_version,omitempty"`
DXVKState string `json:"dxvk_state"`
PowerShellState string `json:"powershell_state"`
LUGVersion string `json:"lug_version,omitempty"`
RSIInstaller string `json:"rsi_installer,omitempty"`
Autopilot bool `json:"autopilot"`
System SystemReadiness `json:"system"`
}
func (a *App) gameConfigPath() string { return filepath.Join(a.configDir, "game.json") }
func (a *App) loadGameConfig() GameConfig {
cfg := GameConfig{}
if data, err := os.ReadFile(a.gameConfigPath()); err == nil {
_ = json.Unmarshal(data, &cfg)
}
if cfg.Prefix == "" {
// Adopt an existing LUG prefix if one is configured, otherwise use our stable default.
old := filepath.Join(envOr("XDG_CONFIG_HOME", filepath.Join(a.home, ".config")), "starcitizen-lug", "winedir.conf")
if data, err := os.ReadFile(old); err == nil && strings.TrimSpace(string(data)) != "" {
cfg.Prefix = strings.TrimSpace(string(data))
} else {
cfg.Prefix = filepath.Join(a.home, "Games", "star-citizen")
}
}
if cfg.GameDir == "" {
cfg.GameDir = filepath.Join(cfg.Prefix, "drive_c", "Program Files", "Roberts Space Industries", "StarCitizen")
}
if cfg.LauncherEXE == "" {
cfg.LauncherEXE = filepath.Join(cfg.Prefix, "drive_c", "Program Files", "Roberts Space Industries", "RSI Launcher", "RSI Launcher.exe")
}
return cfg
}
func (a *App) saveGameConfig(cfg GameConfig) error {
if err := os.MkdirAll(a.configDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return atomicWriteFile(a.gameConfigPath(), data, 0o600)
}
func (a *App) gameStatus() GameStatus {
gc := a.loadGameConfig()
uc := a.loadConfig()
hs := a.hardwareStatus(gc.Prefix)
sys := a.systemReadiness(gc.Prefix)
st := GameStatus{
BackendVersion: appVersion,
Health: "setup",
Hardware: hs.State,
HardwareReason: hs.Reason,
GPU: hs.GPU,
Vulkan: hs.Vulkan,
CPUAVX: hs.AVX,
RAMGiB: hs.RAMGiB,
CombinedGiB: hs.CombinedGiB,
DiskFreeGiB: hs.DiskFreeGiB,
Prefix: gc.Prefix,
PrefixState: "missing",
LauncherState: "missing",
GameState: "missing",
WineVersion: readMetaVersion(filepath.Join(a.vendorDir, "wine", "meta.json")),
DXVKVersion: readMetaVersion(filepath.Join(a.vendorDir, "dxvk", "meta.json")),
DXVKState: "missing",
PowerShellState: "missing",
LUGVersion: readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json")),
RSIInstaller: gc.RSIInstaller,
Autopilot: uc.AutoMaintain,
System: sys,
}
if err := validatePrefixPath(gc.Prefix); err != nil {
st.Health = "hardware-blocked"
st.HardwareReason = err.Error()
return st
}
if hs.State == "blocked" {
st.Health = "hardware-blocked"
return st
}
if sys.State == "blocked" {
st.Health = "hardware-blocked"
st.HardwareReason = sys.Reason
return st
}
if prefixInitialized(gc.Prefix) {
st.PrefixState = "ready"
} else if fi, err := os.Stat(gc.Prefix); err == nil && fi.IsDir() {
st.PrefixState = "partial"
}
if st.PrefixState == "ready" {
st.DXVKState = a.dxvkState(gc.Prefix, st.DXVKVersion)
st.PowerShellState = a.powerShellState(gc.Prefix)
}
if _, err := os.Stat(gc.LauncherEXE); err == nil {
st.LauncherState = "ready"
}
if _, err := os.Stat(filepath.Join(gc.GameDir, "LIVE", "Bin64", "StarCitizen.exe")); err == nil {
st.GameState = "ready"
} else if _, err := os.Stat(filepath.Join(gc.GameDir, "LIVE", "Data.p4k")); err == nil {
st.GameState = "partial"
}
// Around 150 GiB of free space is the current LUG quick-start recommendation
// for a fresh install. Do not block an already-installed game because free
// space naturally drops after installation.
if st.GameState == "missing" && sys.StorageFreeGiB > 0 && sys.StorageFreeGiB < 150 {
st.Health = "hardware-blocked"
st.HardwareReason = fmt.Sprintf("Für eine Neuinstallation sind ungefähr 150 GiB freier Speicher empfohlen; erkannt wurden %d GiB.", sys.StorageFreeGiB)
return st
}
switch {
case sys.State == "prepare":
st.Health = "system-prepare"
case st.WineVersion == "":
st.Health = "setup"
case st.PrefixState == "partial":
st.Health = "repair"
case st.PrefixState != "ready":
st.Health = "install"
case st.DXVKState != "ready":
st.Health = "repair"
case st.PowerShellState != "ready":
st.Health = "repair"
case st.LauncherState != "ready":
st.Health = "launcher-repair"
case st.GameState != "ready":
st.Health = "install-game"
default:
st.Health = "ready"
}
return st
}
type hardwareInfo struct {
State string
Reason string
GPU string
Vulkan string
AVX bool
RAMGiB int
CombinedGiB int
DiskFreeGiB int
}
func (a *App) hardwareStatus(prefix string) hardwareInfo {
h := hardwareInfo{State: "ready", Vulkan: "unknown"}
if runtime.GOARCH != "amd64" {
h.State = "blocked"
h.Reason = "Star Citizen benötigt x86-64."
return h
}
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
low := strings.ToLower(string(data))
h.AVX = regexp.MustCompile(`(?m)^flags\s*:.*\bavx\b`).MatchString(low)
if !h.AVX {
h.State = "blocked"
h.Reason = "Die CPU stellt AVX nicht bereit."
}
}
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
vals := map[string]int64{}
sc := bufio.NewScanner(strings.NewReader(string(data)))
for sc.Scan() {
f := strings.Fields(sc.Text())
if len(f) >= 2 {
n, _ := strconv.ParseInt(f[1], 10, 64)
vals[strings.TrimSuffix(f[0], ":")] = n
}
}
h.RAMGiB = int(vals["MemTotal"] / (1024 * 1024))
h.CombinedGiB = int((vals["MemTotal"] + vals["SwapTotal"]) / (1024 * 1024))
if h.RAMGiB < 16 && h.State != "blocked" {
h.State = "blocked"
h.Reason = "Weniger als 16 GiB RAM erkannt."
} else if h.CombinedGiB < 48 && h.State != "blocked" {
h.State = "blocked"
h.Reason = fmt.Sprintf("RAM + Swap/ZRAM ergeben %d GiB; für Star Citizen werden mindestens 48 GiB benötigt.", h.CombinedGiB)
}
}
var stat syscallStatfs
if statfs(prefix, &stat) == nil {
h.DiskFreeGiB = int((stat.Bavail * uint64(stat.Bsize)) / (1024 * 1024 * 1024))
} else if statfs(a.home, &stat) == nil {
h.DiskFreeGiB = int((stat.Bavail * uint64(stat.Bsize)) / (1024 * 1024 * 1024))
}
pci, _ := exec.Command("lspci", "-nnk").CombinedOutput()
p := string(pci)
if strings.TrimSpace(p) == "" {
// lspci/pciutils is optional on minimal Debian installations. Detect the
// QEMU VGA device through sysfs as a dependency-free fallback.
vendors, _ := filepath.Glob("/sys/class/drm/card*/device/vendor")
for _, vp := range vendors {
v, _ := os.ReadFile(vp)
d, _ := os.ReadFile(filepath.Join(filepath.Dir(vp), "device"))
if strings.TrimSpace(string(v)) == "0x1234" && strings.TrimSpace(string(d)) == "0x1111" {
p = "QEMU Standard VGA [1234:1111]\nKernel driver in use: bochs-drm"
break
}
}
}
for _, line := range strings.Split(p, "\n") {
low := strings.ToLower(line)
if strings.Contains(low, "vga compatible controller") || strings.Contains(low, "3d controller") || strings.Contains(low, "display controller") {
if h.GPU == "" {
h.GPU = strings.TrimSpace(line)
}
}
}
lowp := strings.ToLower(p)
if strings.Contains(lowp, "1234:1111") || strings.Contains(lowp, "kernel driver in use: bochs") {
h.State = "blocked"
h.Vulkan = "blocked"
h.Reason = "QEMU Standard VGA/bochs-drm erkannt. Star Citizen benötigt eine echte Vulkan-GPU oder GPU-Passthrough."
return h
}
if path, err := exec.LookPath("vulkaninfo"); err == nil {
cmd := exec.Command(path, "--summary")
out, err := cmd.CombinedOutput()
text := string(out)
low := strings.ToLower(text)
if err != nil || strings.Contains(text, "VK_ERROR_INCOMPATIBLE_DRIVER") {
h.State = "blocked"
h.Vulkan = "blocked"
h.Reason = "Vulkan ist nicht funktionsfähig."
return h
}
if strings.Contains(low, "device_type_cpu") || strings.Contains(low, "llvmpipe") || strings.Contains(low, "lavapipe") || strings.Contains(low, "softpipe") {
h.State = "blocked"
h.Vulkan = "blocked"
h.Reason = "Es wurde nur ein Software-Vulkan-Gerät erkannt. Star Citizen benötigt eine echte Vulkan-GPU."
return h
}
if m := regexp.MustCompile(`(?m)deviceName\s*=\s*(.+)$`).FindStringSubmatch(text); len(m) == 2 {
h.GPU = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)apiVersion\s*=\s*([0-9]+)\.([0-9]+)`).FindStringSubmatch(text); len(m) == 3 {
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
if major < 1 || (major == 1 && minor < 3) {
h.State = "blocked"
h.Vulkan = "blocked"
h.Reason = fmt.Sprintf("Vulkan %d.%d erkannt; der aktuelle DXVK-Stack benötigt Vulkan 1.3 oder neuer.", major, minor)
return h
}
}
h.Vulkan = "ready"
} else {
matches, _ := filepath.Glob("/usr/share/vulkan/icd.d/*.json")
if len(matches) > 0 {
h.Vulkan = "probable"
} else {
h.Vulkan = "unknown"
}
}
return h
}
// Small local wrapper lets this file remain platform-specific only where needed.
type syscallStatfs struct {
Type, Bsize int64
Blocks, Bfree, Bavail, Files, Ffree, Fsid0, Fsid1 uint64
Namelen, Frsize, Flags int64
Spare [4]int64
}
func statfs(path string, s *syscallStatfs) error {
// Use df instead of syscall.Statfs to keep the struct portable across Go versions.
out, err := exec.Command("df", "-Pk", path).CombinedOutput()
if err != nil {
return err
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return errors.New("df output")
}
f := strings.Fields(lines[len(lines)-1])
if len(f) < 4 {
return errors.New("df fields")
}
avail, _ := strconv.ParseUint(f[3], 10, 64)
s.Bavail = avail
s.Bsize = 1024
return nil
}
func (a *App) currentRunner() (string, error) {
p, err := filepath.EvalSymlinks(filepath.Join(a.vendorDir, "wine", "current"))
if err != nil {
return "", errors.New("kein kompatibler verwalteter Wine-Runner aktiv")
}
if _, err := os.Stat(filepath.Join(p, "bin", "wine")); err != nil {
return "", err
}
return p, nil
}
func envValue(env []string, key string) string {
for i := len(env) - 1; i >= 0; i-- {
k, v, ok := strings.Cut(env[i], "=")
if ok && k == key {
return v
}
}
return ""
}
func setEnvValue(env []string, key, value string) []string {
env = environmentWithout(env, key)
return append(env, key+"="+value)
}
func environmentWithout(env []string, keys ...string) []string {
remove := map[string]bool{}
for _, k := range keys {
remove[k] = true
}
out := make([]string, 0, len(env))
for _, item := range env {
k, _, ok := strings.Cut(item, "=")
if ok && remove[k] {
continue
}
out = append(out, item)
}
return out
}
func (a *App) runnerEnv(prefix string) ([]string, string, error) {
runner, err := a.currentRunner()
if err != nil {
return nil, "", err
}
bin := filepath.Join(runner, "bin")
env := environmentWithout(os.Environ(), "SDL_VIDEODRIVER", "WINE", "WINESERVER", "WINEPREFIX", "WINEARCH", "WINEDEBUG", "WINEDLLOVERRIDES")
env = append(env,
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"WINE="+filepath.Join(bin, "wine"),
"WINESERVER="+filepath.Join(bin, "wineserver"),
"WINEPREFIX="+prefix,
"WINEARCH=win64",
"WINEDEBUG=-all",
"WINEDLLOVERRIDES=dxwebsetup.exe,dotNetFx45_Full_setup.exe,winemenubuilder.exe=d",
)
return env, runner, nil
}
func (a *App) ensureLUGRuntime() (string, error) {
appimage := filepath.Join(a.vendorDir, "lug-helper", "lug-helper.appimage")
ver := readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json"))
if appimage == "" || ver == "" {
return "", errors.New("LUG AppImage runtime ist noch nicht synchronisiert")
}
base := filepath.Join(a.vendorDir, "lug-helper", "runtime", ver)
if _, err := os.Stat(filepath.Join(base, "usr", "bin", "cabextract")); err == nil {
return base, nil
}
if err := os.MkdirAll(filepath.Dir(base), 0o755); err != nil {
return "", err
}
tmp, err := os.MkdirTemp(a.cacheDir, "lug-appimage-")
if err != nil {
return "", err
}
defer os.RemoveAll(tmp)
cmd := exec.Command(appimage, "--appimage-extract")
cmd.Dir = tmp
cmd.Env = append(os.Environ(), "APPIMAGE_EXTRACT_AND_RUN=1")
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("LUG AppImage Runtime konnte nicht entpackt werden: %s", formatCommandFailure(err, out))
}
extracted := filepath.Join(tmp, "squashfs-root")
if _, err := os.Stat(extracted); err != nil {
return "", errors.New("AppImage-Extraktion erzeugte kein squashfs-root")
}
_ = os.RemoveAll(base + ".new")
if err := copyTree(extracted, base+".new"); err != nil {
return "", err
}
_ = os.RemoveAll(base)
if err := os.Rename(base+".new", base); err != nil {
return "", err
}
return base, nil
}
func (a *App) toolsEnv(base []string) ([]string, error) {
// Prefer ordinary distro tools. Debian packages install these explicitly,
// while most desktop distributions already provide curl/unzip.
required := []string{"cabextract", "curl", "unzip"}
allSystem := true
for _, tool := range required {
if _, err := exec.LookPath(tool); err != nil {
allSystem = false
break
}
}
if allSystem {
return append([]string{}, base...), nil
}
// Portable fallback: use only the extracted runtime of the optional LUG
// AppImage as a toolbox. The LUG installer itself is never invoked.
if err := a.syncLUGHelper(); err != nil {
return base, fmt.Errorf("Systemwerkzeuge fehlen (%s) und portable Toolbox konnte nicht geladen werden: %w", strings.Join(required, ", "), err)
}
runtimeRoot, err := a.ensureLUGRuntime()
if err != nil {
return base, err
}
dirs := []string{filepath.Join(runtimeRoot, "usr", "bin"), filepath.Join(runtimeRoot, "bin"), filepath.Join(runtimeRoot, "usr", "sbin")}
libdirs := []string{filepath.Join(runtimeRoot, "usr", "lib"), filepath.Join(runtimeRoot, "usr", "lib64"), filepath.Join(runtimeRoot, "lib"), filepath.Join(runtimeRoot, "lib64")}
env := append([]string{}, base...)
oldPath := envValue(env, "PATH")
newPath := strings.Join(dirs, string(os.PathListSeparator))
if oldPath != "" {
newPath += string(os.PathListSeparator) + oldPath
}
env = setEnvValue(env, "PATH", newPath)
existingLD := envValue(env, "LD_LIBRARY_PATH")
ld := strings.Join(libdirs, string(os.PathListSeparator))
if existingLD != "" {
ld += string(os.PathListSeparator) + existingLD
}
env = setEnvValue(env, "LD_LIBRARY_PATH", ld)
return env, nil
}
func (a *App) syncWinetricks() (string, string, error) {
// Pin the exact Winetricks build used by this Citizen Launcher release.
// Gaming-stack updates should be deterministic: a future Winetricks release
// must first pass our regression tests before a Launcher release adopts it.
tag := winetricksVersion
dir := filepath.Join(a.vendorDir, "winetricks", tag)
target := filepath.Join(dir, "winetricks")
if err := verifySHA256Hex(target, winetricksSHA256); err == nil {
_ = os.Chmod(target, 0o755)
return target, tag, nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", "", err
}
u := "https://raw.githubusercontent.com/Winetricks/winetricks/refs/tags/" + tag + "/src/winetricks"
tmp := target + ".new"
_ = os.Remove(tmp)
if err := download(u, tmp); err != nil {
return "", "", err
}
if err := verifySHA256Hex(tmp, winetricksSHA256); err != nil {
_ = os.Remove(tmp)
return "", "", fmt.Errorf("Winetricks Integritätsprüfung: %w", err)
}
if err := os.Chmod(tmp, 0o755); err != nil {
_ = os.Remove(tmp)
return "", "", err
}
if err := os.Rename(tmp, target); err != nil {
return "", "", err
}
return target, tag, nil
}
func parseRSILatestYML(b []byte) (string, string, error) {
var version, topPath, topSHA, firstURL, firstURLSHA string
urlIndent := -1
scanner := bufio.NewScanner(strings.NewReader(string(b)))
for scanner.Scan() {
raw := strings.TrimRight(scanner.Text(), "\r\n")
trimmed := strings.TrimSpace(raw)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
indent := len(raw) - len(strings.TrimLeft(raw, " \t"))
norm := strings.TrimSpace(strings.TrimPrefix(trimmed, "- "))
key, value, ok := strings.Cut(norm, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = yamlScalar(value)
switch key {
case "version":
if version == "" {
version = value
}
case "path":
if indent == 0 && topPath == "" {
topPath = value
}
case "url":
if firstURL == "" {
firstURL = value
urlIndent = indent
}
case "sha512":
if indent == 0 && topSHA == "" {
topSHA = value
} else if firstURL != "" && firstURLSHA == "" && indent > urlIndent {
firstURLSHA = value
}
}
}
if err := scanner.Err(); err != nil {
return "", "", err
}
file := topPath
sha := topSHA
if file == "" {
file = firstURL
if sha == "" {
sha = firstURLSHA
}
}
// Electron-builder normally supplies path/url, but the version fallback
// keeps us resilient if CIG trims redundant fields from latest.yml.
if file == "" && version != "" {
file = "RSI Launcher-Setup-" + version + ".exe"
}
if file == "" {
return "", "", errors.New("RSI latest.yml enthält weder path, url noch version")
}
return file, sha, nil
}
func yamlScalar(v string) string {
v = strings.TrimSpace(v)
if len(v) >= 2 {
if (v[0] == '\'' && v[len(v)-1] == '\'') || (v[0] == '"' && v[len(v)-1] == '"') {
v = v[1 : len(v)-1]
}
}
return strings.TrimSpace(v)
}
func (a *App) latestRSIInstaller() (string, string, string, error) {
req, _ := http.NewRequest("GET", rsiLatestYML, nil)
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", "", fmt.Errorf("RSI latest.yml HTTP %d", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", "", "", err
}
file, sha, err := parseRSILatestYML(b)
if err != nil {
return "", "", "", err
}
ref, err := neturl.Parse(file)
if err != nil {
return "", "", "", fmt.Errorf("RSI Installer-Pfad ungültig: %w", err)
}
if ref.IsAbs() {
return filepath.Base(ref.Path), ref.String(), sha, nil
}
base, _ := neturl.Parse(rsiBaseURL + "/")
return filepath.Base(ref.Path), base.ResolveReference(ref).String(), sha, nil
}
func verifySHA512Base64(path, want string) error {
if strings.TrimSpace(want) == "" {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha512.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
got := base64.StdEncoding.EncodeToString(h.Sum(nil))
if got != strings.TrimSpace(want) {
return fmt.Errorf("SHA-512 mismatch for %s", filepath.Base(path))
}
return nil
}
func (a *App) backupIncompletePrefix(gc GameConfig) (GameConfig, string, error) {
fi, err := os.Stat(gc.Prefix)
if err != nil || !fi.IsDir() {
return gc, "", nil
}
if prefixInitialized(gc.Prefix) {
return gc, "", nil
}
if entries, err := os.ReadDir(gc.Prefix); err == nil && len(entries) == 0 {
return gc, "", nil
}
stamp := time.Now().Format("20060102-150405")
backup := gc.Prefix + "-incomplete-" + stamp
if err := os.Rename(gc.Prefix, backup); err != nil {
return gc, "", err
}
a.logf("backed up incomplete prefix %s -> %s", gc.Prefix, backup)
return gc, backup, nil
}
func (a *App) gameInstall() error {
lock, err := a.stackOperationLock(2 * time.Second)
if err != nil {
return err
}
defer releaseFileLock(lock)
return a.gameInstallUnlocked()
}
func (a *App) gameInstallUnlocked() error {
gc := a.loadGameConfig()
if err := validatePrefixPath(gc.Prefix); err != nil {
return err
}
if prefixInitialized(gc.Prefix) && prefixBusy(gc.Prefix) {
return errors.New("Der Star-Citizen-Wine-Prefix wird gerade verwendet. Bitte RSI Launcher, Wine-Konfiguration und Spiel schließen und erneut versuchen.")
}
hs := a.hardwareStatus(gc.Prefix)
if hs.State == "blocked" {
return fmt.Errorf("hardware nicht spielbereit: %s", hs.Reason)
}
if sys := a.systemReadiness(gc.Prefix); sys.State == "blocked" {
return errors.New(sys.Reason)
} else if sys.State == "prepare" {
if err := a.ensureSystemPrepared(); err != nil {
return fmt.Errorf("Systemvorbereitung: %w", err)
}
}
if sys := a.systemReadiness(gc.Prefix); sys.StorageFreeGiB > 0 && sys.StorageFreeGiB < 150 {
return fmt.Errorf("zu wenig freier Speicher: %d GiB; für eine Neuinstallation werden ungefähr 150 GiB empfohlen", sys.StorageFreeGiB)
}
// LUG is not part of the installation control path. A portable runtime is
// fetched lazily only when required system tools are missing.
if err := a.syncWineRunner(); err != nil {
return fmt.Errorf("Wine: %w", err)
}
if _, _, err := a.syncWinetricks(); err != nil {
return fmt.Errorf("Winetricks: %w", err)
}
// Adopt a complete existing prefix. Otherwise safely back up partial state.
if !prefixInitialized(gc.Prefix) {
_, backup, err := a.backupIncompletePrefix(gc)
if err != nil {
return err
}
if backup != "" {
a.logf("partial prefix preserved at %s", backup)
}
if err := os.MkdirAll(gc.Prefix, 0o755); err != nil {
return err
}
if err := a.initializePrefix(gc); err != nil {
return err
}
}
if err := a.ensurePrefixComponents(gc); err != nil {
return err
}
if err := a.syncDXVK(); err != nil {
return fmt.Errorf("DXVK: %w", err)
}
if err := a.ensureRSILauncher(gc, true); err != nil {
return err
}
if err := a.writeOwnedLaunchFiles(gc); err != nil {
return err
}
if err := a.writeLUGCompatibilityConfig(gc); err != nil {
a.logf("LUG compatibility config warning: %v", err)
}
gc.RSIInstaller = readText(filepath.Join(a.vendorDir, "rsi", "current.txt"))
gc.WinetricksVersion = readMetaVersion(filepath.Join(a.vendorDir, "winetricks", "meta.json"))
gc.InstalledAt = time.Now().Format(time.RFC3339)
if err := a.saveGameConfig(gc); err != nil {
return err
}
a.logf("game stack installed prefix=%s wine=%s", gc.Prefix, readMetaVersion(filepath.Join(a.vendorDir, "wine", "meta.json")))
return nil
}
func (a *App) initializePrefix(gc GameConfig) error {
env, runner, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
}
env, err = a.toolsEnv(env)
if err != nil {
return err
}
bin := filepath.Join(runner, "bin")
cmd := exec.Command(filepath.Join(bin, "wineboot"), "-u")
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("Wine-Prefix konnte nicht erstellt werden: %s", formatCommandFailure(err, out))
}
wait := exec.Command(filepath.Join(bin, "wineserver"), "-w")
wait.Env = env
if waitOut, waitErr := wait.CombinedOutput(); waitErr != nil {
return fmt.Errorf("Wine-Prefix Abschluss: %s", formatCommandFailure(waitErr, waitOut))
}
deadline := time.Now().Add(30 * time.Second)
for !prefixInitialized(gc.Prefix) && time.Now().Before(deadline) {
time.Sleep(300 * time.Millisecond)
}
if !prefixInitialized(gc.Prefix) {
return errors.New("wineboot meldete Erfolg, aber drive_c/system.reg/user.reg fehlen")
}
return nil
}
func (a *App) ensurePrefixComponents(gc GameConfig) error {
wt, tag, err := a.syncWinetricks()
if err != nil {
return err
}
env, _, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
}
env, err = a.toolsEnv(env)
if err != nil {
return err
}
cache := filepath.Join(a.cacheDir, "winetricks-cache", tag)
_ = os.MkdirAll(cache, 0o755)
env = append(env, "W_CACHE="+cache, "WINETRICKS_DOWNLOADER=curl")
for _, verb := range basePrefixWinetricksVerbs {
cmd := exec.Command(wt, "-q", verb)
cmd.Env = env
out, runErr := cmd.CombinedOutput()
a.logf("winetricks %s verb=%s output=%s", tag, verb, compactDiagnostic(string(out)))
if runErr != nil {
return fmt.Errorf("Windows-Komponente %s: %s", verb, formatCommandFailure(runErr, out))
}
}
// RSI Launcher 2.x uses Windows PowerShell for installer support, game
// directory setup and verification. Avoid the fragile MSI path entirely:
// install a verified portable PowerShell Core plus the Wine wrapper.
if err := a.ensurePowerShell(gc, env); err != nil {
return fmt.Errorf("PowerShell-Kompatibilität: %w", err)
}
runner, _ := a.currentRunner()
wine := filepath.Join(runner, "bin", "wine")
// This is a convenience tweak only: prevent Wine from creating host file
// associations. It must never make the Star Citizen installation fail.
// Use a normal Go string so Wine receives single registry separators.
reg := exec.Command(wine, "reg", "add", wineFileAssociationsKey, "/v", "Enable", "/t", "REG_SZ", "/d", "N", "/f")
reg.Env = env
out, regErr := reg.CombinedOutput()
if regErr != nil {
a.logf("non-fatal Wine registry association tweak warning: %s", formatCommandFailure(regErr, out))
}
// Let Wine finish registry writes before DXVK changes the same prefix.
wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w")
wait.Env = env
if waitOut, waitErr := wait.CombinedOutput(); waitErr != nil {
return fmt.Errorf("Wine-Prefix Abschluss: %s", formatCommandFailure(waitErr, waitOut))
}
_ = writeMeta(filepath.Join(a.vendorDir, "winetricks", "meta.json"), componentMeta{Version: tag, Updated: time.Now().Format(time.RFC3339)})
return nil
}
func (a *App) powerShellPaths(prefix string) (core, profile, wrapper64, wrapper32 string) {
coreDir := filepath.Join(prefix, "drive_c", "Program Files", "PowerShell", "7")
return filepath.Join(coreDir, "pwsh.exe"), filepath.Join(coreDir, "profile.ps1"),
filepath.Join(prefix, "drive_c", "windows", "system32", "WindowsPowerShell", "v1.0", "powershell.exe"),
filepath.Join(prefix, "drive_c", "windows", "syswow64", "WindowsPowerShell", "v1.0", "powershell.exe")
}
func (a *App) powerShellState(prefix string) string {
core, profile, wrapper64, wrapper32 := a.powerShellPaths(prefix)
for _, path := range []string{core, profile, wrapper64, wrapper32} {
fi, err := os.Stat(path)
if err != nil || fi.IsDir() || fi.Size() < 1024 {
return "repair"
}
}
marker := readText(filepath.Join(a.vendorDir, "powershell", "marker.txt"))
managed := powerShellCoreVersion + "+wrapper-" + powerShellWrapperVersion + "\n" + prefix
adopted := "verified\n" + prefix
if marker != managed && marker != adopted {
return "repair"
}
return "ready"
}
func (a *App) writePowerShellMarker(prefix, version string) error {
markerDir := filepath.Join(a.vendorDir, "powershell")
if err := os.MkdirAll(markerDir, 0o755); err != nil {
return err
}
return atomicWriteFile(filepath.Join(markerDir, "marker.txt"), []byte(version+"\n"+prefix+"\n"), 0o600)
}
func (a *App) probePowerShell(gc GameConfig, env []string) error {
_, _, wrapper64, _ := a.powerShellPaths(gc.Prefix)
runner, err := a.currentRunner()
if err != nil {
return err
}
wine := filepath.Join(runner, "bin", "wine")
probe := exec.Command(wine, wrapper64, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Write-Output CITIZEN_POWERSHELL_OK")
probe.Env = env
out, err := probe.CombinedOutput()
if err != nil || !strings.Contains(string(out), "CITIZEN_POWERSHELL_OK") {
return fmt.Errorf("PowerShell Selbsttest: %s", formatCommandFailure(err, out))
}
wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w")
wait.Env = env
if out, err := wait.CombinedOutput(); err != nil {
return fmt.Errorf("PowerShell Abschluss: %s", formatCommandFailure(err, out))
}
return nil
}
func verifySHA256Hex(path, want string) error {
got, err := fileHash(path)
if err != nil {
return err
}
if !strings.EqualFold(strings.TrimSpace(got), strings.TrimSpace(want)) {
return fmt.Errorf("SHA-256 mismatch for %s", filepath.Base(path))
}
return nil
}
func extractZipSafe(path, dir string) error {
r, err := zip.OpenReader(path)
if err != nil {
return err
}
defer r.Close()
cleanRoot, err := filepath.Abs(dir)
if err != nil {
return err
}
if err := os.MkdirAll(cleanRoot, 0o755); err != nil {
return err
}
for _, zf := range r.File {
name := filepath.Clean(filepath.FromSlash(zf.Name))
if name == "." || filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, ".."+string(os.PathSeparator)) {
return fmt.Errorf("unsicherer ZIP-Pfad: %q", zf.Name)
}
target := filepath.Join(cleanRoot, name)
rel, err := filepath.Rel(cleanRoot, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return fmt.Errorf("ZIP-Pfad verlässt Zielverzeichnis: %q", zf.Name)
}
if zf.FileInfo().Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("Symlink im ZIP wird nicht akzeptiert: %q", zf.Name)
}
if zf.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
rc, err := zf.Open()
if err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
rc.Close()
return err
}
_, copyErr := io.Copy(out, io.LimitReader(rc, 512<<20))
closeErr := out.Close()
rc.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
}
return nil
}
func (a *App) ensurePowerShell(gc GameConfig, env []string) error {
if a.powerShellState(gc.Prefix) == "ready" {
return nil
}
// Adopt a working existing Winetricks/manual installation instead of
// overwriting it merely because Citizen Launcher did not create it.
core, profile, wrapper64Existing, wrapper32Existing := a.powerShellPaths(gc.Prefix)
allPresent := true
for _, path := range []string{core, profile, wrapper64Existing, wrapper32Existing} {
fi, err := os.Stat(path)
if err != nil || fi.IsDir() || fi.Size() < 1024 {
allPresent = false
break
}
}
if allPresent {
if err := a.probePowerShell(gc, env); err == nil {
if err := a.writePowerShellMarker(gc.Prefix, "verified"); err != nil {
return err
}
a.logf("adopted existing working PowerShell compatibility layer")
return nil
}
}
cacheDir := filepath.Join(a.cacheDir, "powershell")
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return err
}
coreArchive := filepath.Join(cacheDir, "PowerShell-"+powerShellCoreVersion+"-win-x64.zip")
wrapperArchive := filepath.Join(cacheDir, "powershell-wrapper-"+powerShellWrapperVersion+".zip")
for _, asset := range []struct{ url, path, sha string }{
{powerShellCoreURL, coreArchive, powerShellCoreSHA256},
{powerShellWrapperURL, wrapperArchive, powerShellWrapperSHA256},
} {
if err := verifySHA256Hex(asset.path, asset.sha); err != nil {
_ = os.Remove(asset.path)
if err := download(asset.url, asset.path+".part"); err != nil {
_ = os.Remove(asset.path + ".part")
return err
}
if err := verifySHA256Hex(asset.path+".part", asset.sha); err != nil {
_ = os.Remove(asset.path + ".part")
return err
}
if err := os.Rename(asset.path+".part", asset.path); err != nil {
return err
}
}
}
stage, err := os.MkdirTemp(a.cacheDir, "powershell-stage-")
if err != nil {
return err
}
defer os.RemoveAll(stage)
coreStage := filepath.Join(stage, "core")
wrapperStage := filepath.Join(stage, "wrapper")
if err := extractZipSafe(coreArchive, coreStage); err != nil {
return fmt.Errorf("PowerShell Core entpacken: %w", err)
}
if err := extractZipSafe(wrapperArchive, wrapperStage); err != nil {
return fmt.Errorf("PowerShell Wrapper entpacken: %w", err)
}
if _, err := os.Stat(filepath.Join(coreStage, "pwsh.exe")); err != nil {
return errors.New("PowerShell Core ZIP enthält pwsh.exe nicht")
}
coreTarget := filepath.Join(gc.Prefix, "drive_c", "Program Files", "PowerShell", "7")
_, _, wrapper64Target, wrapper32Target := a.powerShellPaths(gc.Prefix)
managedTargets := []string{coreTarget, wrapper64Target, wrapper32Target}
backups := make(map[string]string, len(managedTargets))
for _, target := range managedTargets {
backup := target + ".citizen-backup"
_ = os.RemoveAll(backup)
if _, err := os.Lstat(target); err == nil {
if err := os.Rename(target, backup); err != nil {
for original, saved := range backups {
_ = os.Rename(saved, original)
}
return fmt.Errorf("vorhandene PowerShell-Datei sichern: %w", err)
}
backups[target] = backup
}
}
restore := true
defer func() {
if !restore {
return
}
for _, target := range managedTargets {
_ = os.RemoveAll(target)
}
for original, saved := range backups {
_ = os.Rename(saved, original)
}
}()
if err := copyTree(coreStage, coreTarget); err != nil {
return fmt.Errorf("PowerShell Core installieren: %w", err)
}
findWrapper := func(bits string) (string, error) {
candidates := []string{
filepath.Join(wrapperStage, bits, "powershell.exe"),
filepath.Join(wrapperStage, "powershell"+bits+".exe"),
}
for _, p := range candidates {
if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 1024 {
return p, nil
}
}
return "", fmt.Errorf("%s-bit PowerShell Wrapper fehlt", bits)
}
wrapper64, err := findWrapper("64")
if err != nil {
return err
}
wrapper32, err := findWrapper("32")
if err != nil {
return err
}
profileSource := filepath.Join(wrapperStage, "profile.ps1")
if _, err := os.Stat(profileSource); err != nil {
return errors.New("PowerShell Wrapper profile.ps1 fehlt")
}
for _, pair := range [][2]string{{wrapper64, wrapper64Target}, {wrapper32, wrapper32Target}, {profileSource, filepath.Join(coreTarget, "profile.ps1")}} {
if err := os.MkdirAll(filepath.Dir(pair[1]), 0o755); err != nil {
return err
}
if err := copyFile(pair[0], pair[1], 0o644); err != nil {
return err
}
}
runner, err := a.currentRunner()
if err != nil {
return err
}
wine := filepath.Join(runner, "bin", "wine")
reg := exec.Command(wine, "reg", "add", `HKEY_CURRENT_USER\Software\Wine\DllOverrides`, "/v", "powershell.exe", "/t", "REG_SZ", "/d", "native,builtin", "/f")
reg.Env = env
if out, err := reg.CombinedOutput(); err != nil {
return fmt.Errorf("PowerShell DLL-Override: %s", formatCommandFailure(err, out))
}
wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w")
wait.Env = env
if out, err := wait.CombinedOutput(); err != nil {
return fmt.Errorf("PowerShell Registry Abschluss: %s", formatCommandFailure(err, out))
}
if err := a.probePowerShell(gc, env); err != nil {
return err
}
if err := a.writePowerShellMarker(gc.Prefix, powerShellCoreVersion+"+wrapper-"+powerShellWrapperVersion); err != nil {
return err
}
restore = false
for _, saved := range backups {
_ = os.RemoveAll(saved)
}
a.logf("PowerShell compatibility ready core=%s wrapper=%s", powerShellCoreVersion, powerShellWrapperVersion)
return nil
}
func (a *App) ensureRSILauncher(gc GameConfig, forceIfMissing bool) error {
file, u, sha, err := a.latestRSIInstaller()
if err != nil {
return err
}
metaDir := filepath.Join(a.vendorDir, "rsi")
if err := os.MkdirAll(metaDir, 0o755); err != nil {
return err
}
old := readText(filepath.Join(metaDir, "current.txt"))
_, launcherErr := os.Stat(gc.LauncherEXE)
if launcherErr == nil && old == file && !forceIfMissing {
return nil
}
if launcherErr == nil && old == file {
return nil
}
cache := filepath.Join(a.cacheDir, file)
if _, err := os.Stat(cache); err != nil {
if err := download(u, cache); err != nil {
return err
}
}
if err := verifySHA512Base64(cache, sha); err != nil {
_ = os.Remove(cache)
return err
}
env, runner, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
}
wine := filepath.Join(runner, "bin", "wine")
cmd := exec.Command(wine, cache, "/S")
cmd.Env = env
out, err := cmd.CombinedOutput()
a.logf("RSI installer %s output=%s", file, compactDiagnostic(string(out)))
if err != nil {
return fmt.Errorf("RSI Launcher Installation: %s", formatCommandFailure(err, out))
}
ws := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-k")
ws.Env = env
_, _ = ws.CombinedOutput()
deadline := time.Now().Add(20 * time.Second)
for {
if _, err := os.Stat(gc.LauncherEXE); err == nil {
break
}
if time.Now().After(deadline) {
return fmt.Errorf("RSI Installer beendet, Launcher fehlt: %s", gc.LauncherEXE)
}
time.Sleep(500 * time.Millisecond)
}
if err := os.WriteFile(filepath.Join(metaDir, "current.txt"), []byte(file+"\n"), 0o644); err != nil {
return err
}
return nil
}
func (a *App) writeOwnedLaunchFiles(gc GameConfig) error {
binDir := filepath.Join(a.dataDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
return err
}
stable := a.stableExecutable()
script := filepath.Join(binDir, "star-citizen-launch")
content := "#!/usr/bin/env bash\nexec " + shellQuote(stable) + " game-launch \"$@\"\n"
if err := atomicWriteFile(script, []byte(content), 0o755); err != nil {
return err
}
apps := a.userApplicationsDir()
if err := os.MkdirAll(apps, 0o755); err != nil {
return err
}
desktop := "[Desktop Entry]\n" +
"Name=Star Citizen\n" +
"Comment=Star Citizen via Citizen Launcher\n" +
"Type=Application\n" +
"Categories=Game;\n" +
"Terminal=false\n" +
"StartupNotify=true\n" +
"StartupWMClass=RSI Launcher.exe\n" +
"Icon=citizen-launcher\n" +
"TryExec=" + stable + "\n" +
"Exec=" + desktopExecQuote(stable) + " game-launch\n"
if strings.Contains(desktop, `\\n`) {
return errors.New("internal desktop-entry newline error")
}
if err := atomicWriteFile(a.starDesktopPath(), []byte(desktop), 0o644); err != nil {
return err
}
if commandExists("update-desktop-database") {
_ = exec.Command("update-desktop-database", apps).Run()
}
return nil
}
func (a *App) writeLUGCompatibilityConfig(gc GameConfig) error {
dir := filepath.Join(envOr("XDG_CONFIG_HOME", filepath.Join(a.home, ".config")), "starcitizen-lug")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "winedir.conf"), []byte(gc.Prefix+"\n"), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "gamedir.conf"), []byte(gc.GameDir+"\n"), 0o644); err != nil {
return err
}
return nil
}
func (a *App) gameLaunch() error {
gc := a.loadGameConfig()
if err := validatePrefixPath(gc.Prefix); err != nil {
return err
}
state := a.prefixProcessState(gc.Prefix)
if state.Game {
return ErrGameAlreadyRunning
}
if state.RSI {
return ErrLauncherAlreadyRunning
}
lock, err := a.stackOperationLock(8 * time.Second)
if err != nil {
return err
}
defer releaseFileLock(lock)
state = a.prefixProcessState(gc.Prefix)
if state.Game {
return ErrGameAlreadyRunning
}
if state.RSI {
return ErrLauncherAlreadyRunning
}
if prefixBusy(gc.Prefix) {
return errors.New("Der Wine-Prefix wird bereits von einem anderen Prozess verwendet. Bitte laufende Wine-Werkzeuge schließen und erneut versuchen.")
}
st := a.gameStatus()
triggerMaintenance := false
if cfg := a.loadConfig(); cfg.AutoMaintain {
triggerMaintenance = true
if t, err := time.Parse(time.RFC3339, cfg.LastMaintenance); err == nil {
triggerMaintenance = time.Since(t) > 6*time.Hour
}
}
if st.Hardware == "blocked" {
return errors.New(st.HardwareReason)
}
if st.System.State == "blocked" {
return errors.New(st.System.Reason)
}
if st.System.State == "prepare" {
if err := a.ensureSystemPrepared(); err != nil {
return fmt.Errorf("Systemvorbereitung: %w", err)
}
}
if err := setLaunchNoFile(); err != nil {
a.logf("launch nofile warning: %v", err)
}
if !prefixInitialized(gc.Prefix) {
return errors.New("Wine-Prefix fehlt; zuerst Setup ausführen")
}
if _, err := os.Stat(gc.LauncherEXE); err != nil {
return errors.New("RSI Launcher fehlt; Reparatur ausführen")
}
if st.DXVKState != "ready" {
return errors.New("DXVK ist nicht vollständig aktiviert; bitte Automatisch reparieren ausführen")
}
if st.PowerShellState != "ready" {
return errors.New("Die RSI-PowerShell-Kompatibilität ist unvollständig; bitte Automatisch reparieren ausführen")
}
env, runner, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
}
env = append(env,
"__GL_SHADER_DISK_CACHE=1",
"__GL_SHADER_DISK_CACHE_SIZE=10737418240",
"__GL_SHADER_DISK_CACHE_PATH="+gc.Prefix,
"__GL_SHADER_DISK_CACHE_SKIP_CLEANUP=1",
"MESA_SHADER_CACHE_DIR="+gc.Prefix,
"MESA_SHADER_CACHE_MAX_SIZE=10G",
)
// A running game/launcher was ruled out above. Clearing the dedicated
// wineserver here only removes stale prefix processes from previous crashes.
ws := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-k")
ws.Env = env
_, _ = ws.CombinedOutput()
log := filepath.Join(a.stateDir, "rsi-launcher.log")
_ = os.MkdirAll(a.stateDir, 0o755)
rotateFile(log, 8*1024*1024, 4*1024*1024)
f, err := os.OpenFile(log, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
cmd := exec.Command(filepath.Join(runner, "bin", "wine"), gc.LauncherEXE)
cmd.Env = env
cmd.Dir = filepath.Dir(gc.LauncherEXE)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Stdout = f
cmd.Stderr = f
if err := cmd.Start(); err != nil {
_ = f.Close()
return err
}
_ = f.Close()
waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
// Wine may hand off to the Windows child and let the wrapper process exit.
// Treat the launch as successful when the actual RSI process appears.
deadline := time.Now().Add(10 * time.Second)
var wrapperErr error
for time.Now().Before(deadline) {
if a.rsiLauncherRunning(gc.Prefix) {
a.logf("RSI Launcher started pid=%d", cmd.Process.Pid)
if triggerMaintenance && !detectPlatform().SystemdUser {
stable := a.stableExecutable()
go func() {
time.Sleep(1500 * time.Millisecond)
_ = exec.Command(stable, "maintain").Start()
}()
}
return nil
}
select {
case wrapperErr = <-waitCh:
if !a.rsiLauncherRunning(gc.Prefix) {
if wrapperErr != nil {
return fmt.Errorf("RSI Launcher wurde früh beendet: %v. Details: %s", wrapperErr, log)
}
return fmt.Errorf("RSI Launcher wurde beendet, bevor sein Prozess sichtbar wurde. Details: %s", log)
}
default:
}
time.Sleep(250 * time.Millisecond)
}
if a.rsiLauncherRunning(gc.Prefix) {
return nil
}
return fmt.Errorf("RSI Launcher wurde gestartet, hat aber innerhalb von 10 Sekunden keinen laufenden Launcher-Prozess erzeugt. Details: %s", log)
}
func (a *App) gameRepair() error {
lock, err := a.stackOperationLock(2 * time.Second)
if err != nil {
return err
}
defer releaseFileLock(lock)
gc := a.loadGameConfig()
if err := validatePrefixPath(gc.Prefix); err != nil {
return err
}
if prefixInitialized(gc.Prefix) && prefixBusy(gc.Prefix) {
return errors.New("Reparatur pausiert: RSI Launcher, Star Citizen oder ein anderes Wine-Werkzeug verwendet den Prefix noch.")
}
hs := a.hardwareStatus(gc.Prefix)
if hs.State == "blocked" {
return errors.New(hs.Reason)
}
if sys := a.systemReadiness(gc.Prefix); sys.State == "blocked" {
return errors.New(sys.Reason)
} else if sys.State == "prepare" {
if err := a.ensureSystemPrepared(); err != nil {
return err
}
}
if err := a.syncWineRunner(); err != nil {
return err
}
if !prefixInitialized(gc.Prefix) {
return a.gameInstallUnlocked()
}
if err := a.ensurePrefixComponents(gc); err != nil {
return err
}
if err := a.ensureRSILauncher(gc, false); err != nil {
return err
}
if err := a.syncDXVK(); err != nil {
return fmt.Errorf("DXVK: %w", err)
}
if err := a.writeOwnedLaunchFiles(gc); err != nil {
return err
}
gc.LastRepair = time.Now().Format(time.RFC3339)
_ = a.saveGameConfig(gc)
return nil
}
func (a *App) syncRSILauncherIfNeeded() error {
gc := a.loadGameConfig()
if !prefixInitialized(gc.Prefix) || prefixBusy(gc.Prefix) {
return nil
}
return a.ensureRSILauncher(gc, false)
}
func readText(path string) string { b, _ := os.ReadFile(path); return strings.TrimSpace(string(b)) }
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" }
// Keep sort referenced here because installation diagnostics intentionally sort paths in future additions.
var _ = sort.Strings