package main import ( "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" "time" ) const ( winetricksRepo = "Winetricks/winetricks" rsiBaseURL = "https://install.robertsspaceindustries.com/rel/2" rsiLatestYML = rsiBaseURL + "/latest.yml" ) 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"` LUGVersion string `json:"lug_version,omitempty"` RSIInstaller string `json:"rsi_installer,omitempty"` Autopilot bool `json:"autopilot"` } 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') tmp := a.gameConfigPath() + ".new" if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } return os.Rename(tmp, a.gameConfigPath()) } func (a *App) gameStatus() GameStatus { gc := a.loadGameConfig() uc := a.loadConfig() hs := a.hardwareStatus(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")), LUGVersion: readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json")), RSIInstaller: gc.RSIInstaller, Autopilot: uc.AutoMaintain, } if hs.State == "blocked" { st.Health = "hardware-blocked" 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 _, 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" } switch { case st.WineVersion == "": st.Health = "setup" case st.PrefixState == "partial": st.Health = "repair" case st.PrefixState != "ready": st.Health = "install" 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 < 14 && h.State != "blocked" { h.State = "blocked" h.Reason = "Weniger als 16 GiB RAM erkannt." } } 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) 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() if err != nil || strings.Contains(string(out), "VK_ERROR_INCOMPATIBLE_DRIVER") { h.State = "blocked" h.Vulkan = "blocked" h.Reason = "Vulkan ist nicht funktionsfähig." 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 (a *App) runnerEnv(prefix string) ([]string, string, error) { runner, err := a.currentRunner() if err != nil { return nil, "", err } bin := filepath.Join(runner, "bin") env := append([]string{}, os.Environ()...) 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) { 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 := os.Getenv("PATH") env = append(env, "PATH="+strings.Join(dirs, string(os.PathListSeparator))+string(os.PathListSeparator)+oldPath) existingLD := os.Getenv("LD_LIBRARY_PATH") ld := strings.Join(libdirs, string(os.PathListSeparator)) if existingLD != "" { ld += string(os.PathListSeparator) + existingLD } env = append(env, "LD_LIBRARY_PATH="+ld) return env, nil } func (a *App) syncWinetricks() (string, string, error) { rel, err := githubLatest(winetricksRepo) if err != nil { return "", "", err } tag := rel.TagName if tag == "" { return "", "", errors.New("Winetricks-Release ohne Tag") } dir := filepath.Join(a.vendorDir, "winetricks", tag) target := filepath.Join(dir, "winetricks") if _, err := os.Stat(target); err == nil { 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" if err := download(u, target+".new"); err != nil { return "", "", err } if err := os.Chmod(target+".new", 0o755); err != nil { return "", "", err } if err := os.Rename(target+".new", target); err != nil { return "", "", err } return target, tag, nil } func (a *App) latestRSIInstaller() (string, string, string, error) { req, _ := http.NewRequest("GET", rsiLatestYML, nil) req.Header.Set("User-Agent", "omarchy-citizen/"+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 } reURL := regexp.MustCompile(`(?m)^url:\s*(.+?)\s*$`) m := reURL.FindSubmatch(b) if len(m) < 2 { return "", "", "", errors.New("RSI latest.yml Format unbekannt") } file := strings.TrimSpace(string(m[1])) file = strings.Trim(file, "\"'") sha := "" reSHA := regexp.MustCompile(`(?m)^sha512:\s*(.+?)\s*$`) if sm := reSHA.FindSubmatch(b); len(sm) >= 2 { sha = strings.TrimSpace(string(sm[1])) sha = strings.Trim(sha, "\"'") } base, _ := neturl.Parse(rsiBaseURL + "/") ref := &neturl.URL{Path: file} return file, 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 { unlock, err := a.acquireMaintenanceLock() if err != nil { return err } defer unlock() gc := a.loadGameConfig() hs := a.hardwareStatus(gc.Prefix) if hs.State == "blocked" { return fmt.Errorf("hardware nicht spielbereit: %s", hs.Reason) } // LUG is optional compatibility/support tooling only. It must never block // the owned installation path and never gets to choose the install runner. if err := a.syncLUGHelper(); err != nil { a.logf("optional LUG runtime warning: %v", err) } 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.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) } if err := a.syncDXVK(); err != nil { a.logf("DXVK post-install 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 _, _ = wait.CombinedOutput() 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") cmd := exec.Command(wt, "-q", "arial", "tahoma", "dxvk", "powershell", "win11") cmd.Env = env out, err := cmd.CombinedOutput() a.logf("winetricks %s output=%s", tag, compactDiagnostic(string(out))) if err != nil { return fmt.Errorf("Prefix-Komponenten: %s", formatCommandFailure(err, out)) } runner, _ := a.currentRunner() wine := filepath.Join(runner, "bin", "wine") reg := exec.Command(wine, "reg", "add", `HKEY_CURRENT_USER\Software\Wine\FileOpenAssociations`, `/v`, `Enable`, `/d`, `N`, `/f`) reg.Env = env out, err = reg.CombinedOutput() if err != nil { return fmt.Errorf("Wine Registry: %s", formatCommandFailure(err, out)) } _ = writeMeta(filepath.Join(a.vendorDir, "winetricks", "meta.json"), componentMeta{Version: tag, Updated: time.Now().Format(time.RFC3339)}) 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 } script := filepath.Join(binDir, "star-citizen-launch") backend := filepath.Join(a.libDir, "omarchy-citizen-backend") content := "#!/usr/bin/env bash\nexec " + shellQuote(backend) + " game-launch\n" if err := os.WriteFile(script, []byte(content), 0o755); err != nil { return err } apps := filepath.Join(envOr("XDG_DATA_HOME", filepath.Join(a.home, ".local", "share")), "applications") _ = os.MkdirAll(apps, 0o755) desktop := `[Desktop Entry] Name=Star Citizen Comment=Star Citizen via Omarchy Citizen Autopilot Type=Application Categories=Game; Terminal=false StartupNotify=true StartupWMClass=rsi launcher.exe Exec=` + script + `\n` if err := os.WriteFile(filepath.Join(apps, "omarchy-citizen-star-citizen.desktop"), []byte(desktop), 0o644); err != nil { return err } 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() st := a.gameStatus() if st.Hardware == "blocked" { return errors.New(st.HardwareReason) } 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") } 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, "MESA_SHADER_CACHE_DIR="+gc.Prefix, "MESA_SHADER_CACHE_MAX_SIZE=10G", ) log := filepath.Join(a.stateDir, "rsi-launcher.log") _ = os.MkdirAll(a.stateDir, 0o755) 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.Stdout = f cmd.Stderr = f if err := cmd.Start(); err != nil { f.Close() return err } _ = f.Close() a.logf("RSI Launcher started pid=%d", cmd.Process.Pid) return nil } func (a *App) gameRepair() error { gc := a.loadGameConfig() hs := a.hardwareStatus(gc.Prefix) if hs.State == "blocked" { return errors.New(hs.Reason) } if err := a.syncLUGHelper(); err != nil { a.logf("LUG runtime warning: %v", err) } if err := a.syncWineRunner(); err != nil { return err } if !prefixInitialized(gc.Prefix) { return a.gameInstall() } if err := a.ensurePrefixComponents(gc); err != nil { return err } if err := a.ensureRSILauncher(gc, false); err != nil { return 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