diff --git a/INSTALLATION.md b/INSTALLATION.md index a52be3f..54a0278 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -1,4 +1,4 @@ -# Omarchy Citizen 0.7.0 – Rundum-Sorglos +# Omarchy Citizen 0.7.1 – Rundum-Sorglos ## Installation @@ -84,3 +84,22 @@ Das Paket enthält zusätzlich: - Wine-Selbsttest - Maintenance-Ergebnis - Updater-Log + +## Wine-Kompatibilitätsfallback (0.7.1) + +Autopilot verwendet nicht blind die höchste Versionsnummer. + +Beispiel: + +```text +11.16-1 testen → nicht kompatibel +11.15-1 testen → nicht kompatibel +11.14-1 testen → funktioniert +→ 11.14-1 wird automatisch aktiviert +``` + +Der Nutzer muss keine Wine-Version auswählen. + +Die konkrete Ursache eines fehlgeschlagenen Tests wird jetzt vollständig ins +Updater-/Support-Log geschrieben, auch wenn `wineboot` selbst keine Textausgabe +liefert. diff --git a/Panel.qml b/Panel.qml index b058ad0..6250601 100644 --- a/Panel.qml +++ b/Panel.qml @@ -12,7 +12,7 @@ Panel { property var anchorItem: null property var hostWidget: null - property string pluginVersion: "0.7.0" + property string pluginVersion: "0.7.1" property string health: "checking" property string depsState: "checking" property string depsMissing: "" @@ -102,7 +102,7 @@ Panel { values[lines[i].slice(0, p)] = lines[i].slice(p + 1) } - pluginVersion = values.plugin_version || "0.7.0" + pluginVersion = values.plugin_version || "0.7.1" health = values.health || "setup" depsState = values.deps || "missing" depsMissing = values.deps_missing || "" diff --git a/README.md b/README.md index 03a9ae8..e1b52e0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Omarchy Citizen 0.7.0 +# Omarchy Citizen 0.7.1 ## Rundum-Sorglos / Autopilot -Omarchy Citizen 0.7.0 changes the architecture from "frontend for packages the +Omarchy Citizen 0.7.1 changes the architecture from "frontend for packages the user maintains" to "self-maintaining Star Citizen appliance". Autopilot manages the complete user-space gaming stack: @@ -68,3 +68,17 @@ Logs: ## License MIT. + +## 0.7.1 compatibility fallback + +Autopilot no longer assumes that the newest LUG Wine release is compatible with +every PC. It evaluates recent stable releases newest-first and activates the +first runner that passes the full local prefix test. + +A rejected immutable release is cached against the current CPU/glibc +fingerprint, so it is not downloaded again every six hours. If the machine's +runtime fingerprint changes, it is eligible for retesting. + +Wine diagnostics now include the actual process error (`signal: illegal +instruction`, missing loader/library, exit status, etc.) even when Wine writes +nothing to stdout. diff --git a/backend/bin/omarchy-citizen-backend b/backend/bin/omarchy-citizen-backend index 435ca53..4ffa0b0 100644 Binary files a/backend/bin/omarchy-citizen-backend and b/backend/bin/omarchy-citizen-backend differ diff --git a/backend/cmd/omarchy-citizen-backend/autopilot.go b/backend/cmd/omarchy-citizen-backend/autopilot.go new file mode 100644 index 0000000..5578426 --- /dev/null +++ b/backend/cmd/omarchy-citizen-backend/autopilot.go @@ -0,0 +1,897 @@ +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"` +} + +type componentMeta struct { + Version string `json:"version"` + Asset string `json:"asset,omitempty"` + URL string `json:"url,omitempty"` + Updated string `json:"updated"` +} + +type wineRejection struct { + Version string `json:"version"` + Reason string `json:"reason"` + Fingerprint string `json:"fingerprint"` + Updated string `json:"updated"` +} + +func (a *App) enableAutopilot() error { + if err := a.installService(); err != nil { + return err + } + cfg := a.loadConfig() + cfg.AutoMaintain = true + + // Plugin auto-update is enabled automatically only for a clean, trusted Git checkout. + // The gaming stack does not depend on Git and remains fully auto-maintained either way. + st, _ := a.status(true) + if st.Managed == "git" && !st.Dirty && st.Remote != "" { + cfg.AutoApply = true + if cfg.TrustedRemote == "" { + cfg.TrustedRemote = st.Remote + } + } + cfg.LastResult = "autopilot-enabled" + a.saveConfig(cfg) + + if err := run("", "systemctl", "--user", "enable", "--now", "omarchy-citizen-update.timer"); err != nil { + return err + } + a.logf("autopilot enabled auto_plugin=%v", cfg.AutoApply) + + // First maintenance is intentionally synchronous: after installation the stack + // is already usable/current instead of waiting for the timer. + if err := a.maintainGamingStack(); err != nil { + return err + } + fmt.Println("enabled") + return nil +} + +func (a *App) disableAutopilot() error { + cfg := a.loadConfig() + cfg.AutoMaintain = false + cfg.LastResult = "autopilot-disabled" + a.saveConfig(cfg) + 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() + + a.logf("maintenance begin") + var problems []string + + if err := a.syncLUGHelper(); err != nil { + problems = append(problems, "LUG Helper: "+err.Error()) + } + if err := a.syncWineRunner(); err != nil { + problems = append(problems, "Wine runner: "+err.Error()) + } + if err := a.syncDXVK(); err != nil { + problems = append(problems, "DXVK: "+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.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 := 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 := selectAsset(release.Assets, 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") { + return false + } + return strings.Contains(n, "lug-wine-tkg-git") && + (strings.HasSuffix(n, ".tar.xz") || strings.HasSuffix(n, ".tar.zst") || + strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz")) + }) + 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 + } + + // Already-active compatible version: no download/test needed. + if readMetaVersion(metaPath) == release.TagName { + if p, _ := filepath.EvalSymlinks(current); p != "" { + if _, err := os.Stat(filepath.Join(p, "bin", "wine")); err == nil { + a.activateRunnerForPrefix(p) + return nil + } + } + } + + 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 + } + + stage, err := os.MkdirTemp(a.cacheDir, "wine-stage-") + if err != nil { + return err + } + + extract := exec.Command("tar", "-xf", archive, "-C", stage) + out, extractErr := extract.CombinedOutput() + if extractErr != nil { + os.RemoveAll(stage) + failures = append(failures, release.TagName+" extract: "+formatCommandFailure(extractErr, out)) + 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 + } + + 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), + }); 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 + } + + 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 := append(os.Environ(), + "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 text == "" { + return err.Error() + } + // Keep status output compact but preserve enough detail in updater.log. + if len(text) > 1200 { + text = text[len(text)-1200:] + } + 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 { + 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) + } +} + +func (a *App) syncDXVK() error { + prefix := a.configuredPrefix() + if prefix == "" || !prefixInitialized(prefix) { + return nil + } + if prefixBusy(prefix) { + a.logf("DXVK update deferred: Star Citizen Wine prefix is active") + return nil + } + + 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") + if readMetaVersion(metaPath) == release.TagName { + 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 + } + stage, err := os.MkdirTemp(a.cacheDir, "dxvk-stage-") + if err != nil { + return err + } + defer os.RemoveAll(stage) + if out, err := exec.Command("tar", "-xf", archive, "-C", stage).CombinedOutput(); err != nil { + return fmt.Errorf("extract DXVK: %s", strings.TrimSpace(string(out))) + } + 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 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", 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 { + configHome := envOr("XDG_CONFIG_HOME", filepath.Join(a.home, ".config")) + data, err := os.ReadFile(filepath.Join(configHome, "starcitizen-lug", "winedir.conf")) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +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 + } + needle := []byte("WINEPREFIX=" + prefix) + 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 && strings.Contains(string(data), string(needle)) { + 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", "omarchy-citizen/"+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", "omarchy-citizen/"+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 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", "omarchy-citizen/"+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 readMetaVersion(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var meta componentMeta + if json.Unmarshal(data, &meta) != nil { + return "" + } + return meta.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') + tmp := path + ".new" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func (a *App) saveConfig(cfg Config) { + _ = os.MkdirAll(a.configDir, 0o755) + data, _ := json.MarshalIndent(cfg, "", " ") + data = append(data, '\n') + tmp := a.configPath + ".tmp" + if os.WriteFile(tmp, data, 0o600) == nil { + _ = os.Rename(tmp, a.configPath) + } +} + +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 Omarchy Citizen maintenance run is active") + } + return func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil +} diff --git a/backend/cmd/omarchy-citizen-backend/autopilot_test.go b/backend/cmd/omarchy-citizen-backend/autopilot_test.go new file mode 100644 index 0000000..10406e4 --- /dev/null +++ b/backend/cmd/omarchy-citizen-backend/autopilot_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + "testing" +) + +func TestSelectLUGAppImage(t *testing.T) { + assets := []githubAsset{ + {Name: "lug-helper-v4.16.tar.gz"}, + {Name: "lug-helper-v4.16.appimage"}, + {Name: "lug-helper-v4.16.appimage.zsync"}, + } + a, err := selectAsset(assets, func(n string) bool { + n = lower(n) + return suffix(n, ".appimage") && contains(n, "lug-helper") + }) + if err != nil || a.Name != "lug-helper-v4.16.appimage" { + t.Fatalf("unexpected selection: %#v %v", a, err) + } +} + +func TestSelectStableWine(t *testing.T) { + assets := []githubAsset{ + {Name: "lug-wine-tkg-staging-git-11.16-1-x86_64.tar.xz"}, + {Name: "lug-wine-tkg-git-11.16-1-x86_64.tar.xz"}, + {Name: "SHA256SUMS"}, + } + a, err := selectAsset(assets, func(n string) bool { + n = lower(n) + if contains(n, "staging") || contains(n, "checksum") || contains(n, "sha") { + return false + } + return contains(n, "lug-wine-tkg-git") && suffix(n, ".tar.xz") + }) + if err != nil || a.Name != "lug-wine-tkg-git-11.16-1-x86_64.tar.xz" { + t.Fatalf("unexpected selection: %#v %v", a, err) + } +} + +// tiny wrappers keep tests readable without importing strings again +func lower(s string) string { return strings.ToLower(s) } +func suffix(s, p string) bool { return strings.HasSuffix(s, p) } +func contains(s, p string) bool { return strings.Contains(s, p) } + +func TestFormatCommandFailureIncludesProcessError(t *testing.T) { + err := fmt.Errorf("signal: illegal instruction") + got := formatCommandFailure(err, nil) + if !strings.Contains(got, "illegal instruction") { + t.Fatalf("process error was lost: %q", got) + } +} + +func TestWineRejectionRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "rejected.json") + in := map[string]wineRejection{ + "11.16-1": {Version: "11.16-1", Reason: "signal: illegal instruction", Fingerprint: "cpu-a"}, + } + if err := writeWineRejections(path, in); err != nil { + t.Fatal(err) + } + out := readWineRejections(path) + if out["11.16-1"].Reason != in["11.16-1"].Reason { + t.Fatalf("round trip mismatch: %#v", out) + } +} diff --git a/backend/cmd/omarchy-citizen-backend/main.go b/backend/cmd/omarchy-citizen-backend/main.go index bb2227c..f3cd0ea 100644 --- a/backend/cmd/omarchy-citizen-backend/main.go +++ b/backend/cmd/omarchy-citizen-backend/main.go @@ -16,7 +16,7 @@ import ( ) const ( - appVersion = "0.7.0" + appVersion = "0.7.1" pluginID = "local.omarchy-citizen" ) diff --git a/citizenctl b/citizenctl index 97f641a..11693bc 100644 --- a/citizenctl +++ b/citizenctl @@ -2,7 +2,7 @@ set -u PLUGIN_ID="local.omarchy-citizen" -PLUGIN_VERSION="0.7.0" +PLUGIN_VERSION="0.7.1" CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/starcitizen-lug" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}" diff --git a/install.sh b/install.sh index f73cc80..39159e2 100644 --- a/install.sh +++ b/install.sh @@ -48,7 +48,7 @@ omarchy plugin enable "$PLUGIN_ID" omarchy restart shell >/dev/null 2>&1 || true echo -echo "Omarchy Citizen 0.7.0 wurde installiert:" +echo "Omarchy Citizen 0.7.1 wurde installiert:" echo " $DEST" echo echo "Links-Klick auf 'SC' in der Omarchy-Leiste öffnet das Star-Citizen-Panel." diff --git a/manifest.json b/manifest.json index d38d568..47c93bb 100644 --- a/manifest.json +++ b/manifest.json @@ -2,10 +2,10 @@ "schemaVersion": 1, "id": "local.omarchy-citizen", "name": "Omarchy Citizen", - "version": "0.7.0", + "version": "0.7.1", "author": "Community prototype", "license": "MIT", - "description": "Plug-and-play Star Citizen for Omarchy with Autopilot: automatic plugin, LUG Helper AppImage, LUG Wine runner and DXVK maintenance, health checks, self-healing setup and sanitized support bundles.", + "description": "Omarchy Citizen Autopilot with compatibility fallback: automatically selects the newest LUG Wine runner that passes a real Wine prefix self-test on this PC.", "kinds": [ "bar-widget" ],