diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..67e728e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,17 @@ +# Architecture + +## Layers + +1. **Core (`backend/cmd/citizen-launcher`)** – single source of truth for detection, setup, repair, launch and maintenance. +2. **Standalone GUI** – embedded HTML/CSS/JS served only on `127.0.0.1` behind a random per-process URL token. +3. **Desktop integration** – `.desktop` launcher, systemd user maintenance timer when available. +4. **Distribution adapters** – `/etc/os-release` and package-manager detection. The core does not depend on a package manager for Wine/DXVK/RSI. +5. **Omarchy adapter** – optional Quickshell plugin that calls the same Go core. + +## Why a local embedded web GUI? + +It lets one static Go binary present the same polished UI on Debian, Fedora and Arch without linking to a particular desktop toolkit. The HTTP server binds to loopback only and uses an unguessable route token. No remote content is required to render the UI. + +## Update strategy + +Gaming components are versioned independently and activated only after local validation. Wine uses newest-compatible fallback rather than newest-at-all-costs. Distribution packages and GPU drivers are outside the self-update trust boundary. diff --git a/DISTRO_SUPPORT.md b/DISTRO_SUPPORT.md new file mode 100644 index 0000000..c0ded96 --- /dev/null +++ b/DISTRO_SUPPORT.md @@ -0,0 +1,13 @@ +# Distribution support + +Citizen Launcher deliberately avoids distro-specific Wine packages. + +| Distribution family | Install method | Autopilot | Notes | +|---|---|---|---| +| Debian / Ubuntu / Mint | `.deb` or `install.sh` | systemd user timer | GPU/Vulkan driver must be working | +| Fedora | generic tarball / `install.sh` | systemd user timer | RPM spec included | +| Arch / Omarchy | `install.sh` | systemd user timer | optional Omarchy bar integration | +| openSUSE | generic tarball / `install.sh` | systemd user timer | package-manager independent core | +| non-systemd desktop | generic install | maintenance on app use | timer is skipped | + +The launcher detects `apt`, `dnf`, `pacman`, `zypper` and `apk` for diagnostics/UI, but does not silently alter the base OS. diff --git a/README.md b/README.md index 83730f2..4f21cfa 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,81 @@ -# Omarchy Citizen 0.8.1 — Development Preview +# Citizen Launcher 0.9.0 -Omarchy Citizen is moving from a frontend around the LUG installer to an -**owned, self-maintaining Star Citizen stack for Omarchy**. +Citizen Launcher is the distro-neutral successor to Omarchy Citizen. + +## Goal + +**Install Linux → install Citizen Launcher → log into RSI → play.** + +The core is one static Go binary and does not depend on Omarchy. Omarchy is an optional integration under `integrations/omarchy/`. + +## Supported targets + +Primary targets: + +- Debian / Ubuntu / Linux Mint +- Fedora +- Arch Linux / Omarchy +- openSUSE (generic user install) + +Other amd64 Linux distributions can use the generic tarball when they provide a working Vulkan driver and the runtime requirements of the selected Wine runner. + +## GUI + +`citizen-launcher gui` starts a localhost-only UI from files embedded in the Go binary. Chromium-family browsers open it as a dedicated app window; otherwise it opens in the default browser. No GTK/Qt/WebKit development/runtime package is required by Citizen Launcher itself. ## Architecture -The critical install and launch path is owned by this project: - -1. hardware / Vulkan readiness gate -2. select the newest locally compatible stable LUG Wine runner -3. validate it in a disposable Wine prefix -4. create the real Wine prefix -5. dynamically fetch current Winetricks and install required verbs -6. dynamically fetch and verify the current RSI Launcher -7. manage DXVK and the stable launch entry point -8. periodically maintain the user-space gaming stack - -LUG Helper is **optional support/compatibility tooling**. Its failure must not -block installation and it never chooses the active Wine runner. - -## Stable paths - -User-space components live below: - ```text -~/.local/share/omarchy-citizen/ -~/.local/state/omarchy-citizen/ -~/.config/omarchy-citizen/ +Standalone GUI / Omarchy bar integration + ↓ + Citizen Launcher + Go core + ↓ + ┌───────────────┼────────────────┐ + Hardware Wine RSI Launcher + readiness selector installer + + self-test + ↓ + Wine prefix + ↓ + Winetricks + DXVK + ↓ + Star Citizen ``` -The backend intentionally hides upstream version/layout changes behind stable -`vendor/*/current` paths and its own game configuration. +The managed game stack lives in XDG user directories: -## Update safety +- `~/.config/citizen-launcher` +- `~/.local/share/citizen-launcher` +- `~/.local/state/citizen-launcher` +- `~/.cache/citizen-launcher` -- Wine candidates are tested before activation. -- Incompatible immutable Wine releases are cached per CPU/glibc fingerprint. -- Partial prefixes are backed up rather than deleted. -- RSI installer downloads are checked against the SHA-512 value from its - published update metadata. -- DXVK is not changed while the Wine prefix is active. -- Plugin Git updates retain the Omarchy validation / trusted-remote protection. +Existing `omarchy-citizen` data is migrated when possible. -## Current development status +## Build -See `DEVELOPMENT_STATUS.md`. +```bash +./build.sh +``` -## License +## Install (any desktop distro) -MIT. +```bash +./install.sh +``` +For Omarchy plus bar integration: -## 0.8.1 adapter hotfix +```bash +./install-omarchy.sh +``` -The 0.8.0 preview accidentally mixed legacy shell actions with the new Go-owned -stack. `citizenctl` has been rewritten as a thin adapter. There is now one -authoritative implementation for install/status/launch/repair: the Go backend. +## Debian package -A regression test exercises the adapter with a fake backend, including the exact -full-setup path that previously failed with `backend_game_status: command not -found` / `notify: command not found`. +```bash +./packaging/build-deb.sh +``` + +## Security boundaries + +Citizen Launcher manages its Wine/DXVK/RSI stack entirely as the current user. It does not create passwordless sudo/pacman/apt rules. GPU/kernel/base-system updates remain owned by the distribution. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..f789850 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,36 @@ +# Citizen Launcher 0.9.0 — distro-neutral preview + +This release turns Omarchy Citizen into an optional integration instead of the product core. + +## New + +- standalone distro-neutral `citizen-launcher` Go binary +- embedded polished local GUI (`citizen-launcher gui`) +- Debian/Ubuntu `.deb` packaging +- generic Linux amd64 tarball +- Fedora RPM spec template +- distro detection via `/etc/os-release` +- package-manager detection for apt/dnf/pacman/zypper/apk diagnostics +- XDG-native config/data/state/cache paths with legacy Omarchy-Citizen migration +- generic systemd user maintenance timer, with launch-time maintenance fallback on non-systemd desktops +- support bundles generated entirely in Go +- Omarchy moved to `integrations/omarchy/` +- system tools preferred; LUG AppImage runtime is only a lazy portability fallback when cabextract/curl/unzip are unavailable + +## Validation performed + +- `go test ./...` +- `go vet ./...` +- static Linux amd64 Go build +- shell syntax verification +- embedded GUI HTTP/API smoke test +- generic user installer smoke test in an isolated HOME +- Omarchy updater integration test +- Debian package build and metadata inspection + +## Still needs real-hardware testing + +- clean Debian/Ubuntu desktop → RSI login/install → Star Citizen launch +- Fedora/openSUSE hardware testing +- AMD/NVIDIA/Intel Vulkan permutations +- production standalone self-update release channel once an official repository/release URL is chosen diff --git a/backend/README.md b/backend/README.md index fd7c7de..bfca046 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,69 +1,22 @@ -# Omarchy Citizen Go Update Backend +# Citizen Launcher Go Core -`omarchy-citizen-backend` is a small, dependency-free Go helper for plugin updates. -It deliberately does **not** implement its own plugin package manager. For Git-managed -Omarchy plugins it delegates the actual update to: - -```bash -omarchy plugin update local.omarchy-citizen --yes -``` - -This preserves Omarchy's own fast-forward, manifest validation and rollback behavior. +The Go core is the single source of truth for Star Citizen setup, launch, repair and maintenance. ## Commands ```text -omarchy-citizen-backend status [--json] -omarchy-citizen-backend check [--json] -omarchy-citizen-backend update -omarchy-citizen-backend tick -omarchy-citizen-backend auto enable -omarchy-citizen-backend auto disable -omarchy-citizen-backend auto status -omarchy-citizen-backend install-service -omarchy-citizen-backend uninstall-service -omarchy-citizen-backend self-sync -omarchy-citizen-backend version +citizen-launcher gui +citizen-launcher platform [--json] +citizen-launcher game-status [--json] +citizen-launcher game-install +citizen-launcher game-launch +citizen-launcher game-repair +citizen-launcher maintain +citizen-launcher doctor +citizen-launcher support +citizen-launcher autopilot enable|disable|status ``` -## Update trust model +The optional Omarchy integration also exposes the legacy integration update commands. -Automatic updates can only be enabled when the plugin directory is a Git checkout. -When the user enables automatic updates, the current `origin` URL is stored as the -trusted remote. Future unattended updates are refused if: - -- the Git remote changes, -- the checkout contains local changes, -- the branch has diverged, -- Omarchy validation fails, -- another update is already running. - -## Scheduling - -`auto enable` creates/enables a systemd **user** timer. It checks approximately every -six hours with a randomized delay. No root service and no localhost HTTP server are used. - -## State - -```text -~/.config/omarchy-citizen/updater.json -~/.local/state/omarchy-citizen/updater.log -~/.local/state/omarchy-citizen/update.lock -~/.local/lib/omarchy-citizen/omarchy-citizen-backend -``` - -The external binary location is intentional: the updater can keep running while the -plugin Git checkout itself is fast-forwarded. After a successful plugin update it copies -the newly bundled backend binary into the external location atomically. - -## Building - -```bash -cd backend -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ - -trimpath -ldflags='-s -w' \ - -o bin/omarchy-citizen-backend \ - ./cmd/omarchy-citizen-backend -``` - -The repository should commit the resulting binary so users do not need a Go toolchain. +Build with `./build.sh`. The output is `bin/citizen-launcher`. diff --git a/backend/bin/citizen-launcher b/backend/bin/citizen-launcher new file mode 100644 index 0000000..3ca1c3c Binary files /dev/null and b/backend/bin/citizen-launcher differ diff --git a/backend/build.sh b/backend/build.sh index 618d36e..80294ac 100644 --- a/backend/build.sh +++ b/backend/build.sh @@ -4,6 +4,6 @@ cd "$(dirname "$0")" mkdir -p bin CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ -trimpath -ldflags='-s -w' \ - -o bin/omarchy-citizen-backend \ - ./cmd/omarchy-citizen-backend -sha256sum bin/omarchy-citizen-backend + -o bin/citizen-launcher \ + ./cmd/citizen-launcher +sha256sum bin/citizen-launcher diff --git a/backend/cmd/citizen-launcher/autopilot.go b/backend/cmd/citizen-launcher/autopilot.go new file mode 100644 index 0000000..e9f9117 --- /dev/null +++ b/backend/cmd/citizen-launcher/autopilot.go @@ -0,0 +1,929 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "syscall" + "time" +) + +const ( + lugRepo = "starcitizen-lug/lug-helper" + wineRepo = "starcitizen-lug/lug-wine" + dxvkRepo = "doitsujin/dxvk" +) + +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []githubAsset `json:"assets"` +} + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` + Digest string `json:"digest,omitempty"` +} + +type componentMeta struct { + Version string `json:"version"` + Asset string `json:"asset,omitempty"` + URL string `json:"url,omitempty"` + Updated string `json:"updated"` +} + +type wineRejection struct { + Version string `json:"version"` + Reason string `json:"reason"` + Fingerprint string `json:"fingerprint"` + Updated string `json:"updated"` +} + +func (a *App) enableAutopilot() error { + cfg := a.loadConfig() + cfg.AutoMaintain = true + + // The optional Omarchy integration may also be eligible for plugin updates. + st, _ := a.status(true) + if st.Managed == "omarchy-git" && !st.Dirty && st.Remote != "" { + cfg.AutoApply = true + if cfg.TrustedRemote == "" { + cfg.TrustedRemote = st.Remote + } + } + cfg.LastResult = "autopilot-enabled" + a.saveConfig(cfg) + + // systemd is common on Debian/Fedora/Arch/openSUSE, but not required by the + // launcher. Non-systemd desktops are maintained opportunistically on launch. + if detectPlatform().SystemdUser { + if err := a.installService(); err != nil { + return err + } + if err := run("", "systemctl", "--user", "enable", "--now", "citizen-launcher-maintenance.timer"); err != nil { + return err + } + } else { + if err := a.selfSync(); err != nil { + return err + } + a.logf("autopilot enabled without systemd user manager; launch-time maintenance active") + } + a.logf("autopilot enabled auto_integration=%v", cfg.AutoApply) + fmt.Println("enabled") + return nil +} + +func (a *App) disableAutopilot() error { + cfg := a.loadConfig() + cfg.AutoMaintain = false + cfg.LastResult = "autopilot-disabled" + a.saveConfig(cfg) + 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 + + // LUG is no longer synchronized during normal maintenance. It is only a + // lazy portable-toolbox fallback when distro tools are missing. + 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()) + } + if err := a.syncRSILauncherIfNeeded(); err != nil { + problems = append(problems, "RSI Launcher: "+err.Error()) + } + + result := "ok" + if len(problems) > 0 { + result = strings.Join(problems, " | ") + } + a.updateConfig(func(c *Config) { + c.LastMaintenance = time.Now().Format(time.RFC3339) + c.MaintenanceResult = result + }) + a.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 := verifyReleaseDigest(tmp, asset.Digest); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Chmod(tmp, 0o755); err != nil { + return err + } + if err := os.Rename(tmp, target); err != nil { + return err + } + if err := writeMeta(metaPath, componentMeta{ + Version: release.TagName, Asset: asset.Name, URL: asset.BrowserDownloadURL, + Updated: time.Now().Format(time.RFC3339), + }); err != nil { + return err + } + a.logf("LUG Helper updated to %s asset=%s", release.TagName, asset.Name) + return nil +} + +func (a *App) syncWineRunner() error { + releases, err := githubRecent(wineRepo, 10) + if err != nil { + return err + } + if len(releases) == 0 { + return errors.New("LUG Wine repository returned no releases") + } + + base := filepath.Join(a.vendorDir, "wine") + metaPath := filepath.Join(base, "meta.json") + current := filepath.Join(base, "current") + rejectPath := filepath.Join(base, "rejected.json") + fingerprint := compatibilityFingerprint() + rejections := readWineRejections(rejectPath) + + if err := os.MkdirAll(base, 0o755); err != nil { + return err + } + if err := os.MkdirAll(a.cacheDir, 0o755); err != nil { + return err + } + + var failures []string + for _, release := range releases { + asset, assetErr := 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 + } + if err := verifyReleaseDigest(archive, asset.Digest); err != nil { + _ = os.Remove(archive) + failures = append(failures, release.TagName+" checksum: "+err.Error()) + continue + } + + stage, err := os.MkdirTemp(a.cacheDir, "wine-stage-") + if err != nil { + return err + } + + 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 + } + if err := verifyReleaseDigest(archive, asset.Digest); err != nil { + _ = os.Remove(archive) + return err + } + stage, err := os.MkdirTemp(a.cacheDir, "dxvk-stage-") + if err != nil { + return err + } + defer os.RemoveAll(stage) + if 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 { + return a.loadGameConfig().Prefix +} + +func prefixInitialized(prefix string) bool { + for _, p := range []string{"drive_c", "system.reg", "user.reg"} { + if _, err := os.Stat(filepath.Join(prefix, p)); err != nil { + return false + } + } + return true +} + +func prefixBusy(prefix string) bool { + proc, err := os.ReadDir("/proc") + if err != nil { + return false + } + 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", "citizen-launcher/"+appVersion) + client := &http.Client{Timeout: 45 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub %s returned HTTP %d", repo, resp.StatusCode) + } + var releases []githubRelease + if err := json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(&releases); err != nil { + return nil, err + } + return releases, nil +} + +func compatibilityFingerprint() string { + var parts []string + if out, err := exec.Command("getconf", "GNU_LIBC_VERSION").CombinedOutput(); err == nil { + parts = append(parts, strings.TrimSpace(string(out))) + } + if data, err := os.ReadFile("/proc/cpuinfo"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(strings.ToLower(line), "flags") { + parts = append(parts, strings.TrimSpace(line)) + break + } + } + } + if out, err := exec.Command("uname", "-m").CombinedOutput(); err == nil { + parts = append(parts, strings.TrimSpace(string(out))) + } + return strings.Join(parts, " | ") +} + +func readWineRejections(path string) map[string]wineRejection { + result := map[string]wineRejection{} + data, err := os.ReadFile(path) + if err != nil { + return result + } + _ = json.Unmarshal(data, &result) + return result +} + +func writeWineRejections(path string, values map[string]wineRejection) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(values, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + tmp := path + ".new" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func githubLatest(repo string) (githubRelease, error) { + var rel githubRelease + req, err := http.NewRequest("GET", "https://api.github.com/repos/"+repo+"/releases/latest", nil) + if err != nil { + return rel, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "citizen-launcher/"+appVersion) + client := &http.Client{Timeout: 45 * time.Second} + resp, err := client.Do(req) + if err != nil { + return rel, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return rel, fmt.Errorf("GitHub %s returned HTTP %d", repo, resp.StatusCode) + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&rel); err != nil { + return rel, err + } + if rel.TagName == "" { + return rel, errors.New("release has no tag") + } + return rel, nil +} + +func selectAsset(assets []githubAsset, match func(string) bool) (githubAsset, error) { + var candidates []githubAsset + for _, a := range assets { + if match(a.Name) { + candidates = append(candidates, a) + } + } + if len(candidates) == 0 { + return githubAsset{}, errors.New("no matching release asset") + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name < candidates[j].Name }) + return candidates[0], nil +} + +func download(url, target string) error { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "citizen-launcher/"+appVersion) + client := &http.Client{Timeout: 20 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("download returned HTTP %d", resp.StatusCode) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + _, copyErr := io.Copy(f, resp.Body) + closeErr := f.Close() + if copyErr != nil { + _ = os.Remove(target) + return copyErr + } + return closeErr +} + +func findRootContaining(base, relative string) (string, error) { + var matches []string + err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() && d.Name() == filepath.Base(filepath.Dir(relative)) { + candidate := filepath.Join(filepath.Dir(path), relative) + if _, err := os.Stat(candidate); err == nil { + matches = append(matches, filepath.Dir(path)) + } + } + return nil + }) + if err != nil { + return "", err + } + if len(matches) == 0 { + // Robust fallback: inspect every directory for the relative path. + _ = filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { + if err == nil && d.IsDir() { + if _, e := os.Stat(filepath.Join(path, relative)); e == nil { + matches = append(matches, path) + } + } + return nil + }) + } + if len(matches) == 0 { + return "", fmt.Errorf("archive does not contain %s", relative) + } + sort.Strings(matches) + return matches[0], nil +} + +func copyTree(src, dst string) error { + return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + info, err := d.Info() + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + link, err := os.Readlink(path) + if err != nil { + return err + } + return os.Symlink(link, target) + } + mode := info.Mode().Perm() + if mode == 0 { + mode = 0o644 + } + return copyFile(path, target, mode) + }) +} + +func 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 Citizen Launcher maintenance run is active") + } + return func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil +} + +func verifyReleaseDigest(path, digest string) error { + if digest == "" { + return nil + } + parts := strings.SplitN(digest, ":", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "sha256" { + return nil + } + want := strings.ToLower(strings.TrimSpace(parts[1])) + got, err := fileHash(path) + if err != nil { + return err + } + if strings.ToLower(got) != want { + return fmt.Errorf("SHA-256 mismatch for %s", filepath.Base(path)) + } + return nil +} diff --git a/backend/cmd/citizen-launcher/autopilot_test.go b/backend/cmd/citizen-launcher/autopilot_test.go new file mode 100644 index 0000000..10406e4 --- /dev/null +++ b/backend/cmd/citizen-launcher/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/citizen-launcher/gui.go b/backend/cmd/citizen-launcher/gui.go new file mode 100644 index 0000000..7567430 --- /dev/null +++ b/backend/cmd/citizen-launcher/gui.go @@ -0,0 +1,204 @@ +package main + +import ( + "crypto/rand" + "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net" + "net/http" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +//go:embed web/* +var guiFiles embed.FS + +type GUIStatus struct { + Version string `json:"version"` + Platform PlatformStatus `json:"platform"` + Game GameStatus `json:"game"` + Launcher Status `json:"launcher"` +} + +type GUIJob struct { + ID string `json:"id"` + Action string `json:"action"` + State string `json:"state"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + Started string `json:"started"` + Finished string `json:"finished,omitempty"` +} + +type jobStore struct { + mu sync.Mutex + jobs map[string]*GUIJob +} + +func (a *App) runGUI(args []string) error { + noOpen := hasArg(args, "--no-open") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return err + } + tokenBytes := make([]byte, 18) + _, _ = rand.Read(tokenBytes) + token := hex.EncodeToString(tokenBytes) + base := "/" + token + jobs := &jobStore{jobs: map[string]*GUIJob{}} + mux := http.NewServeMux() + sub, _ := fs.Sub(guiFiles, "web") + mux.Handle(base+"/assets/", http.StripPrefix(base+"/assets/", http.FileServer(http.FS(sub)))) + mux.HandleFunc(base+"/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != base+"/" { + http.NotFound(w, r) + return + } + b, err := fs.ReadFile(sub, "index.html") + if err != nil { + http.Error(w, err.Error(), 500) + return + } + b = []byte(strings.ReplaceAll(string(b), "__BASE__", base)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(b) + }) + jsonOut := func(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) + } + mux.HandleFunc(base+"/api/status", func(w http.ResponseWriter, r *http.Request) { + st, _ := a.status(false) + jsonOut(w, GUIStatus{Version: appVersion, Platform: detectPlatform(), Game: a.gameStatus(), Launcher: st}) + }) + mux.HandleFunc(base+"/api/job/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, base+"/api/job/") + jobs.mu.Lock() + j := jobs.jobs[id] + jobs.mu.Unlock() + if j == nil { + http.NotFound(w, r) + return + } + jsonOut(w, j) + }) + mux.HandleFunc(base+"/api/action/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "POST required", 405) + return + } + action := strings.TrimPrefix(r.URL.Path, base+"/api/action/") + if action == "launch" { + if err := a.gameLaunch(); err != nil { + http.Error(w, err.Error(), 500) + return + } + jsonOut(w, map[string]string{"state": "started"}) + return + } + idBytes := make([]byte, 8) + _, _ = rand.Read(idBytes) + id := hex.EncodeToString(idBytes) + j := &GUIJob{ID: id, Action: action, State: "running", Started: time.Now().Format(time.RFC3339)} + jobs.mu.Lock() + jobs.jobs[id] = j + jobs.mu.Unlock() + go func() { + var err error + var msg string + switch action { + case "setup": + if e := a.enableAutopilot(); e != nil { + a.logf("autopilot enable during setup: %v", e) + } + err = a.gameInstall() + case "repair": + err = a.gameRepair() + case "maintain": + err = a.maintainGamingStack() + case "doctor": + err = a.wineDoctor() + case "autopilot-enable": + err = a.enableAutopilot() + case "autopilot-disable": + err = a.disableAutopilot() + case "support": + var p string + p, err = a.createSupportBundle() + msg = p + case "integration-update": + err = a.update(false) + default: + err = errors.New("unknown action") + } + jobs.mu.Lock() + defer jobs.mu.Unlock() + j.Finished = time.Now().Format(time.RFC3339) + if err != nil { + j.State = "error" + j.Error = err.Error() + } else { + j.State = "done" + j.Message = msg + } + }() + jsonOut(w, j) + }) + mux.HandleFunc(base+"/api/open/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "POST required", 405) + return + } + what := strings.TrimPrefix(r.URL.Path, base+"/api/open/") + var path string + switch what { + case "downloads": + path = filepath.Join(a.home, "Downloads") + case "logs": + path = a.stateDir + default: + http.NotFound(w, r) + return + } + _ = exec.Command("xdg-open", path).Start() + jsonOut(w, map[string]string{"state": "opened"}) + }) + srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} + url := "http://" + listener.Addr().String() + base + "/" + fmt.Println(url) + if !noOpen { + if err := openGUIURL(url, a.cacheDir); err != nil { + _ = listener.Close() + return err + } + } + return srv.Serve(listener) +} + +func openGUIURL(url, cache string) error { + candidates := []string{"chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "brave-browser", "brave", "vivaldi", "vivaldi-stable"} + for _, name := range candidates { + if p, err := exec.LookPath(name); err == nil { + cmd := exec.Command(p, "--app="+url, "--new-window") + cmd.Stdout = nil + cmd.Stderr = nil + if cmd.Start() == nil { + return nil + } + } + } + if p, err := exec.LookPath("xdg-open"); err == nil { + return exec.Command(p, url).Start() + } + if p, err := exec.LookPath("gio"); err == nil { + return exec.Command(p, "open", url).Start() + } + return fmt.Errorf("no browser opener found; open %s manually", url) +} diff --git a/backend/cmd/citizen-launcher/main.go b/backend/cmd/citizen-launcher/main.go new file mode 100644 index 0000000..75e4219 --- /dev/null +++ b/backend/cmd/citizen-launcher/main.go @@ -0,0 +1,846 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +const ( + appVersion = "0.9.0" + pluginID = "local.omarchy-citizen" + appID = "io.github.citizenlauncher.CitizenLauncher" +) + +type Config struct { + AutoApply bool `json:"auto_apply"` + AutoMaintain bool `json:"auto_maintain"` + TrustedRemote string `json:"trusted_remote,omitempty"` + LastCheck string `json:"last_check,omitempty"` + LastUpdate string `json:"last_update,omitempty"` + LastResult string `json:"last_result,omitempty"` + LastMaintenance string `json:"last_maintenance,omitempty"` + MaintenanceResult string `json:"maintenance_result,omitempty"` +} + +type Status struct { + BackendVersion string `json:"backend_version"` + PluginID string `json:"plugin_id"` + PluginDir string `json:"plugin_dir"` + Managed string `json:"managed"` + AutoApply bool `json:"auto_apply"` + AutoMaintain bool `json:"auto_maintain"` + LUGVersion string `json:"lug_version,omitempty"` + WineVersion string `json:"wine_version,omitempty"` + DXVKVersion string `json:"dxvk_version,omitempty"` + LastMaintenance string `json:"last_maintenance,omitempty"` + MaintenanceResult string `json:"maintenance_result,omitempty"` + Remote string `json:"remote,omitempty"` + TrustedRemote string `json:"trusted_remote,omitempty"` + Branch string `json:"branch,omitempty"` + LocalCommit string `json:"local_commit,omitempty"` + RemoteCommit string `json:"remote_commit,omitempty"` + Dirty bool `json:"dirty"` + Update string `json:"update"` + LastCheck string `json:"last_check,omitempty"` + LastUpdate string `json:"last_update,omitempty"` + LastResult string `json:"last_result,omitempty"` +} + +type App struct { + home string + pluginDir string + configDir string + stateDir string + dataDir string + cacheDir string + vendorDir string + configPath string + logPath string + libDir string + selfPath string +} + +func main() { + app, err := newApp() + if err != nil { + fatal(err) + } + app.rotateLog() + + args := os.Args[1:] + if len(args) == 0 { + args = []string{"status"} + } + + switch args[0] { + case "version", "--version", "-v": + fmt.Println(appVersion) + case "status": + st, _ := app.status(false) + if hasArg(args[1:], "--json") { + printJSON(st) + } else { + printStatusKV(st) + } + case "check": + st, err := app.status(true) + app.recordCheck(st, err) + if hasArg(args[1:], "--json") { + printJSON(st) + } else { + printStatusKV(st) + } + if err != nil { + os.Exit(2) + } + case "update": + if err := app.update(false); err != nil { + app.logf("manual update failed: %v", err) + fatal(err) + } + case "tick": + if err := app.tick(); err != nil { + app.logf("tick failed: %v", err) + os.Exit(2) + } + case "maintain": + if err := app.maintainGamingStack(); err != nil { + app.logf("maintenance failed: %v", err) + fatal(err) + } + case "doctor": + if err := app.wineDoctor(); err != nil { + fatal(err) + } + case "game-status": + st := app.gameStatus() + if hasArg(args[1:], "--json") { + printJSON(st) + } else { + printGameStatusKV(st) + } + case "game-install": + if err := app.gameInstall(); err != nil { + fatal(err) + } + case "game-launch": + if err := app.gameLaunch(); err != nil { + fatal(err) + } + case "game-repair": + if err := app.gameRepair(); err != nil { + fatal(err) + } + case "gui": + if err := app.runGUI(args[1:]); err != nil { + fatal(err) + } + case "platform": + ps := detectPlatform() + if hasArg(args[1:], "--json") { + printJSON(ps) + } else { + printPlatformKV(ps) + } + case "support": + path, err := app.createSupportBundle() + if err != nil { + fatal(err) + } + fmt.Println(path) + case "autopilot": + if len(args) < 2 { + fatal(errors.New("usage: autopilot enable|disable|status")) + } + switch args[1] { + case "enable": + if err := app.enableAutopilot(); err != nil { + fatal(err) + } + case "disable": + if err := app.disableAutopilot(); err != nil { + fatal(err) + } + case "status": + cfg := app.loadConfig() + if cfg.AutoMaintain { + fmt.Println("enabled") + } else { + fmt.Println("disabled") + } + default: + fatal(errors.New("usage: autopilot enable|disable|status")) + } + case "auto": + if len(args) < 2 { + fatal(errors.New("usage: auto enable|disable|status")) + } + switch args[1] { + case "enable": + if err := app.enableAuto(); err != nil { + fatal(err) + } + case "disable": + if err := app.disableAuto(); err != nil { + fatal(err) + } + case "status": + cfg := app.loadConfig() + if cfg.AutoApply { + fmt.Println("enabled") + } else { + fmt.Println("disabled") + } + default: + fatal(errors.New("usage: auto enable|disable|status")) + } + case "install-service": + if err := app.installService(); err != nil { + fatal(err) + } + case "uninstall-service": + if err := app.uninstallService(); err != nil { + fatal(err) + } + case "self-sync": + if err := app.selfSync(); err != nil { + fatal(err) + } + default: + fatal(fmt.Errorf("unknown command: %s", args[0])) + } +} + +func newApp() (*App, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + self, _ := os.Executable() + self, _ = filepath.EvalSymlinks(self) + configHome := envOr("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + stateHome := envOr("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) + dataHome := envOr("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) + cacheHome := envOr("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + + a := &App{ + home: home, + pluginDir: filepath.Join(configHome, "omarchy", "plugins", pluginID), + configDir: filepath.Join(configHome, "citizen-launcher"), + stateDir: filepath.Join(stateHome, "citizen-launcher"), + dataDir: filepath.Join(dataHome, "citizen-launcher"), + cacheDir: filepath.Join(cacheHome, "citizen-launcher"), + configPath: filepath.Join(configHome, "citizen-launcher", "updater.json"), + logPath: filepath.Join(stateHome, "citizen-launcher", "updater.log"), + libDir: filepath.Join(home, ".local", "lib", "citizen-launcher"), + selfPath: self, + } + a.vendorDir = filepath.Join(a.dataDir, "vendor") + _ = a.migrateLegacyPaths(configHome, stateHome, dataHome) + return a, nil +} + +func (a *App) migrateLegacyPaths(configHome, stateHome, dataHome string) error { + pairs := [][2]string{ + {filepath.Join(configHome, "omarchy-citizen"), a.configDir}, + {filepath.Join(stateHome, "omarchy-citizen"), a.stateDir}, + {filepath.Join(dataHome, "omarchy-citizen"), a.dataDir}, + } + for _, pair := range pairs { + if _, err := os.Stat(pair[1]); err == nil { + continue + } + if fi, err := os.Stat(pair[0]); err == nil && fi.IsDir() { + _ = os.MkdirAll(filepath.Dir(pair[1]), 0o755) + if err := os.Rename(pair[0], pair[1]); err != nil { + // Cross-filesystem and permission-safe fallback: leave legacy data in place. + // loadGameConfig() separately adopts the legacy Star Citizen prefix. + a.logf("legacy migration warning %s -> %s: %v", pair[0], pair[1], err) + } + } + } + return nil +} + +func (a *App) status(fetch bool) (Status, error) { + cfg := a.loadConfig() + st := Status{ + BackendVersion: appVersion, + PluginID: pluginID, + PluginDir: a.pluginDir, + Managed: "manual", + AutoApply: cfg.AutoApply, + AutoMaintain: cfg.AutoMaintain, + LUGVersion: readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json")), + WineVersion: readMetaVersion(filepath.Join(a.vendorDir, "wine", "meta.json")), + DXVKVersion: readMetaVersion(filepath.Join(a.vendorDir, "dxvk", "meta.json")), + LastMaintenance: cfg.LastMaintenance, + MaintenanceResult: cfg.MaintenanceResult, + TrustedRemote: cfg.TrustedRemote, + Update: "unavailable", + LastCheck: cfg.LastCheck, + LastUpdate: cfg.LastUpdate, + LastResult: cfg.LastResult, + } + + if !isGitRepo(a.pluginDir) || !commandExists("omarchy") { + st.Managed = "standalone" + st.Update = "external-release" + return st, nil + } + st.Managed = "omarchy-git" + + remote, _ := git(a.pluginDir, "remote", "get-url", "origin") + st.Remote = strings.TrimSpace(remote) + branch, _ := git(a.pluginDir, "branch", "--show-current") + st.Branch = strings.TrimSpace(branch) + local, _ := git(a.pluginDir, "rev-parse", "HEAD") + st.LocalCommit = short(strings.TrimSpace(local)) + dirty, _ := git(a.pluginDir, "status", "--porcelain") + st.Dirty = strings.TrimSpace(dirty) != "" + + if fetch { + if st.Dirty { + st.Update = "blocked-dirty" + return st, errors.New("plugin checkout has local changes") + } + if cfg.TrustedRemote != "" && st.Remote != cfg.TrustedRemote { + st.Update = "blocked-remote-changed" + return st, errors.New("git origin differs from trusted remote") + } + if _, err := git(a.pluginDir, "fetch", "--quiet", "origin"); err != nil { + st.Update = "check-failed" + return st, err + } + } + + upstream, err := git(a.pluginDir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + if err != nil { + st.Update = "no-upstream" + return st, nil + } + upstream = strings.TrimSpace(upstream) + remoteCommit, err := git(a.pluginDir, "rev-parse", upstream) + if err != nil { + st.Update = "unknown" + return st, err + } + remoteCommit = strings.TrimSpace(remoteCommit) + st.RemoteCommit = short(remoteCommit) + localFull, _ := git(a.pluginDir, "rev-parse", "HEAD") + localFull = strings.TrimSpace(localFull) + + switch { + case st.Dirty: + st.Update = "blocked-dirty" + case localFull == remoteCommit: + st.Update = "current" + default: + ancestor, err := runExit(a.pluginDir, "git", "merge-base", "--is-ancestor", localFull, remoteCommit) + if err == nil && ancestor == 0 { + st.Update = "available" + } else { + st.Update = "diverged" + } + } + return st, nil +} + +func (a *App) tick() error { + cfg := a.loadConfig() + var problems []string + + if cfg.AutoMaintain { + if err := a.maintainGamingStack(); err != nil { + problems = append(problems, "gaming stack: "+err.Error()) + } + } + + if cfg.AutoApply { + st, err := a.status(true) + a.recordCheck(st, err) + if err != nil { + problems = append(problems, "plugin check: "+err.Error()) + } else if st.Update == "available" { + if err := a.update(true); err != nil { + problems = append(problems, "plugin update: "+err.Error()) + } + } + } + + if len(problems) > 0 { + return errors.New(strings.Join(problems, "; ")) + } + return nil +} + +func (a *App) update(automatic bool) error { + unlock, err := a.acquireUpdateLock() + if err != nil { + return err + } + defer unlock() + + st, err := a.status(true) + a.recordCheck(st, err) + if err != nil { + return err + } + if st.Managed != "omarchy-git" { + return errors.New("this update path is only available for the optional Omarchy Git integration") + } + cfg := a.loadConfig() + if cfg.TrustedRemote != "" && st.Remote != cfg.TrustedRemote { + return errors.New("refusing update: git origin changed after auto-update trust was granted") + } + if st.Dirty { + return errors.New("refusing update: plugin contains local changes") + } + if st.Update == "current" { + a.logf("update: already current at %s", st.LocalCommit) + return nil + } + if st.Update != "available" { + return fmt.Errorf("update cannot be applied in state %q", st.Update) + } + + before := st.LocalCommit + a.logf("update begin automatic=%v remote=%q from=%s to=%s", automatic, st.Remote, st.LocalCommit, st.RemoteCommit) + + cmd := exec.Command("omarchy", "plugin", "update", pluginID, "--yes") + cmd.Stdout = io.MultiWriter(os.Stdout, a.logWriter()) + cmd.Stderr = io.MultiWriter(os.Stderr, a.logWriter()) + if err := cmd.Run(); err != nil { + a.updateConfig(func(c *Config) { + c.LastResult = "update-failed" + }) + return fmt.Errorf("omarchy plugin update failed: %w", err) + } + + if err := a.validatePlugin(); err != nil { + a.updateConfig(func(c *Config) { + c.LastResult = "post-validation-failed" + }) + return fmt.Errorf("post-update validation failed: %w", err) + } + + after, _ := git(a.pluginDir, "rev-parse", "HEAD") + after = short(strings.TrimSpace(after)) + a.updateConfig(func(c *Config) { + c.LastUpdate = time.Now().Format(time.RFC3339) + c.LastResult = "updated:" + before + "->" + after + }) + a.logf("update success from=%s to=%s", before, after) + a.notify("Citizen Launcher", "Plugin-Update installiert: "+before+" → "+after) + + if err := a.selfSync(); err != nil { + a.logf("backend self-sync warning: %v", err) + } + _ = run("", "omarchy-shell", "shell", "rescanPlugins") + if !automatic { + // Current Omarchy releases can keep stale third-party bar QML after a rescan. + // A user-triggered update may safely request a full shell restart so the + // freshly validated plugin code becomes active immediately. + _ = run("", "omarchy", "restart", "shell") + } + return nil +} + +func (a *App) enableAuto() error { + st, err := a.status(true) + if err != nil { + return err + } + if st.Managed != "omarchy-git" || st.Remote == "" { + return errors.New("integration auto-updates require the optional Omarchy Git integration") + } + if st.Dirty { + return errors.New("cannot enable auto updates while the plugin checkout has local changes") + } + if err := a.validatePlugin(); err != nil { + return err + } + if err := a.installService(); err != nil { + return err + } + a.updateConfig(func(c *Config) { + c.AutoApply = true + c.TrustedRemote = st.Remote + c.LastResult = "auto-enabled" + }) + if err := run("", "systemctl", "--user", "enable", "--now", "citizen-launcher-maintenance.timer"); err != nil { + return err + } + a.logf("auto updates enabled trusted_remote=%q", st.Remote) + fmt.Println("enabled") + return nil +} + +func (a *App) disableAuto() error { + a.updateConfig(func(c *Config) { + c.AutoApply = false + c.LastResult = "auto-disabled" + }) + _ = run("", "systemctl", "--user", "disable", "--now", "citizen-launcher-maintenance.timer") + a.logf("auto updates disabled") + fmt.Println("disabled") + return nil +} + +func (a *App) installService() error { + if err := os.MkdirAll(filepath.Join(a.home, ".config", "systemd", "user"), 0o755); err != nil { + return err + } + if err := os.MkdirAll(a.libDir, 0o755); err != nil { + return err + } + if err := a.selfSync(); err != nil { + return err + } + + servicePath := filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.service") + timerPath := filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.timer") + backendPath := filepath.Join(a.libDir, "citizen-launcher") + + service := `[Unit]\nDescription=Citizen Launcher Autopilot maintenance\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart=` + backendPath + ` tick\n` + timer := `[Unit]\nDescription=Maintain Citizen Launcher gaming stack automatically\n\n[Timer]\nOnBootSec=3min\nOnUnitActiveSec=6h\nRandomizedDelaySec=15min\nPersistent=true\nUnit=citizen-launcher-maintenance.service\n\n[Install]\nWantedBy=timers.target\n` + + if err := os.WriteFile(servicePath, []byte(strings.ReplaceAll(service, `\n`, "\n")), 0o644); err != nil { + return err + } + if err := os.WriteFile(timerPath, []byte(strings.ReplaceAll(timer, `\n`, "\n")), 0o644); err != nil { + return err + } + return run("", "systemctl", "--user", "daemon-reload") +} + +func (a *App) uninstallService() error { + _ = run("", "systemctl", "--user", "disable", "--now", "citizen-launcher-maintenance.timer") + _ = os.Remove(filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.service")) + _ = os.Remove(filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.timer")) + _ = run("", "systemctl", "--user", "daemon-reload") + return nil +} + +func (a *App) selfSync() error { + if err := os.MkdirAll(a.libDir, 0o755); err != nil { + return err + } + target := filepath.Join(a.libDir, "citizen-launcher") + source := a.selfPath + // Omarchy integration ships a bundled copy; prefer it when present. + bundled := filepath.Join(a.pluginDir, "backend", "bin", "citizen-launcher") + if _, err := os.Stat(bundled); err == nil { + source = bundled + } + if source == "" { + return errors.New("cannot locate Citizen Launcher executable") + } + same, _ := sameFileHash(source, target) + if !same { + tmp := target + ".new" + if err := copyFile(source, tmp, 0o755); err != nil { + return err + } + if err := os.Rename(tmp, target); err != nil { + return err + } + } + // Compatibility alias for older Omarchy Citizen adapters. + legacyDir := filepath.Join(a.home, ".local", "lib", "omarchy-citizen") + _ = os.MkdirAll(legacyDir, 0o755) + legacy := filepath.Join(legacyDir, "omarchy-citizen-backend") + _ = os.Remove(legacy) + _ = os.Symlink(target, legacy) + a.logf("self-sync installed %s", target) + return nil +} + +func (a *App) acquireUpdateLock() (func(), error) { + if err := os.MkdirAll(a.stateDir, 0o755); err != nil { + return nil, err + } + path := filepath.Join(a.stateDir, "update.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 update is already running") + } + return func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil +} + +func (a *App) notify(title, body string) { + if _, err := exec.LookPath("omarchy-notification-send"); err == nil { + _ = exec.Command("omarchy-notification-send", title, body).Run() + return + } + if _, err := exec.LookPath("notify-send"); err == nil { + _ = exec.Command("notify-send", title, body).Run() + } +} + +func (a *App) validatePlugin() error { + cmd := exec.Command("omarchy", "plugin", "validate", a.pluginDir) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("plugin validation failed: %s", strings.TrimSpace(string(out))) + } + return nil +} + +func (a *App) recordCheck(st Status, err error) { + a.updateConfig(func(c *Config) { + c.LastCheck = time.Now().Format(time.RFC3339) + if err != nil { + c.LastResult = "check-failed:" + err.Error() + } else { + c.LastResult = "check:" + st.Update + } + }) + a.logf("check managed=%s update=%s local=%s remote=%s dirty=%v err=%v", st.Managed, st.Update, st.LocalCommit, st.RemoteCommit, st.Dirty, err) +} + +func (a *App) loadConfig() Config { + var cfg Config + data, err := os.ReadFile(a.configPath) + if err == nil { + _ = json.Unmarshal(data, &cfg) + } + return cfg +} + +func (a *App) updateConfig(fn func(*Config)) { + cfg := a.loadConfig() + fn(&cfg) + _ = 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) rotateLog() { + _ = os.MkdirAll(a.stateDir, 0o755) + info, err := os.Stat(a.logPath) + if err != nil || info.Size() <= 4*1024*1024 { + return + } + f, err := os.Open(a.logPath) + if err != nil { + return + } + defer f.Close() + keep := int64(2 * 1024 * 1024) + if _, err := f.Seek(-keep, io.SeekEnd); err != nil { + return + } + data, _ := io.ReadAll(f) + _ = os.WriteFile(a.logPath, data, 0o600) +} + +func (a *App) logWriter() io.Writer { + _ = os.MkdirAll(a.stateDir, 0o755) + f, err := os.OpenFile(a.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return io.Discard + } + return &autoCloseWriter{f: f} +} + +func (a *App) logf(format string, args ...any) { + _ = os.MkdirAll(a.stateDir, 0o755) + f, err := os.OpenFile(a.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return + } + defer f.Close() + fmt.Fprintf(f, "%s version=%s %s\n", time.Now().Format(time.RFC3339), appVersion, fmt.Sprintf(format, args...)) +} + +type autoCloseWriter struct{ f *os.File } + +func (w *autoCloseWriter) Write(p []byte) (int, error) { return w.f.Write(p) } + +func git(dir string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out))) + } + return string(out), nil +} + +func run(dir, name string, args ...string) error { + cmd := exec.Command(name, args...) + if dir != "" { + cmd.Dir = dir + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func runExit(dir, name string, args ...string) (int, error) { + cmd := exec.Command(name, args...) + if dir != "" { + cmd.Dir = dir + } + err := cmd.Run() + if err == nil { + return 0, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), nil + } + return -1, err +} + +func isGitRepo(dir string) bool { + out, err := git(dir, "rev-parse", "--is-inside-work-tree") + return err == nil && strings.TrimSpace(out) == "true" +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + if err := out.Sync(); err != nil { + out.Close() + return err + } + return out.Close() +} + +func sameFileHash(a, b string) (bool, error) { + ha, err := fileHash(a) + if err != nil { + return false, err + } + hb, err := fileHash(b) + if err != nil { + return false, err + } + return ha == hb, nil +} + +func fileHash(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} +func short(s string) string { + if len(s) > 12 { + return s[:12] + } + return s +} +func hasArg(args []string, needle string) bool { + for _, a := range args { + if a == needle { + return true + } + } + return false +} +func printJSON(v any) { enc := json.NewEncoder(os.Stdout); enc.SetIndent("", " "); _ = enc.Encode(v) } +func printStatusKV(s Status) { + fmt.Printf("backend_version=%s\n", s.BackendVersion) + fmt.Printf("managed=%s\n", s.Managed) + fmt.Printf("auto_apply=%t\n", s.AutoApply) + fmt.Printf("auto_maintain=%t\n", s.AutoMaintain) + fmt.Printf("lug_version=%s\n", s.LUGVersion) + fmt.Printf("wine_version=%s\n", s.WineVersion) + fmt.Printf("dxvk_version=%s\n", s.DXVKVersion) + fmt.Printf("last_maintenance=%s\n", s.LastMaintenance) + fmt.Printf("maintenance_result=%s\n", s.MaintenanceResult) + fmt.Printf("update=%s\n", s.Update) + fmt.Printf("remote=%s\n", s.Remote) + fmt.Printf("trusted_remote=%s\n", s.TrustedRemote) + fmt.Printf("branch=%s\n", s.Branch) + fmt.Printf("local_commit=%s\n", s.LocalCommit) + fmt.Printf("remote_commit=%s\n", s.RemoteCommit) + fmt.Printf("dirty=%t\n", s.Dirty) + fmt.Printf("last_check=%s\n", s.LastCheck) + fmt.Printf("last_update=%s\n", s.LastUpdate) + fmt.Printf("last_result=%s\n", s.LastResult) +} +func commandExists(name string) bool { _, err := exec.LookPath(name); return err == nil } + +func printPlatformKV(p PlatformStatus) { + fmt.Printf("id=%s\n", p.ID) + fmt.Printf("name=%s\n", p.Name) + fmt.Printf("version=%s\n", p.Version) + fmt.Printf("id_like=%s\n", p.IDLike) + fmt.Printf("package_manager=%s\n", p.PackageManager) + fmt.Printf("desktop=%s\n", p.Desktop) + fmt.Printf("session=%s\n", p.Session) + fmt.Printf("systemd_user=%t\n", p.SystemdUser) + fmt.Printf("omarchy=%t\n", p.Omarchy) +} + +func fatal(err error) { fmt.Fprintln(os.Stderr, "citizen-launcher:", err); os.Exit(1) } + +func printGameStatusKV(s GameStatus) { + fmt.Printf("backend_version=%s\n", s.BackendVersion) + fmt.Printf("health=%s\n", s.Health) + fmt.Printf("hardware=%s\n", s.Hardware) + fmt.Printf("hardware_reason=%s\n", s.HardwareReason) + fmt.Printf("gpu=%s\n", s.GPU) + fmt.Printf("vulkan=%s\n", s.Vulkan) + fmt.Printf("cpu_avx=%t\n", s.CPUAVX) + fmt.Printf("ram_gib=%d\n", s.RAMGiB) + fmt.Printf("combined_gib=%d\n", s.CombinedGiB) + fmt.Printf("disk_free_gib=%d\n", s.DiskFreeGiB) + fmt.Printf("prefix=%s\n", s.Prefix) + fmt.Printf("prefix_state=%s\n", s.PrefixState) + fmt.Printf("launcher_state=%s\n", s.LauncherState) + fmt.Printf("game_state=%s\n", s.GameState) + fmt.Printf("wine_version=%s\n", s.WineVersion) + fmt.Printf("dxvk_version=%s\n", s.DXVKVersion) + fmt.Printf("lug_version=%s\n", s.LUGVersion) + fmt.Printf("rsi_installer=%s\n", s.RSIInstaller) + fmt.Printf("autopilot=%t\n", s.Autopilot) +} diff --git a/backend/cmd/citizen-launcher/platform.go b/backend/cmd/citizen-launcher/platform.go new file mode 100644 index 0000000..d10cc1e --- /dev/null +++ b/backend/cmd/citizen-launcher/platform.go @@ -0,0 +1,71 @@ +package main + +import ( + "bufio" + "os" + "os/exec" + "strings" +) + +type PlatformStatus struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + IDLike string `json:"id_like,omitempty"` + PackageManager string `json:"package_manager"` + Desktop string `json:"desktop,omitempty"` + Session string `json:"session,omitempty"` + SystemdUser bool `json:"systemd_user"` + Omarchy bool `json:"omarchy"` +} + +func detectPlatform() PlatformStatus { + p := PlatformStatus{ID: "linux", Name: "Linux"} + if f, err := os.Open("/etc/os-release"); err == nil { + defer f.Close() + vals := map[string]string{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if ok { + vals[k] = strings.Trim(strings.TrimSpace(v), `"'`) + } + } + if vals["ID"] != "" { + p.ID = vals["ID"] + } + if vals["PRETTY_NAME"] != "" { + p.Name = vals["PRETTY_NAME"] + } else if vals["NAME"] != "" { + p.Name = vals["NAME"] + } + p.Version = vals["VERSION_ID"] + p.IDLike = vals["ID_LIKE"] + } + switch { + case commandExists("apt-get"): + p.PackageManager = "apt" + case commandExists("dnf"): + p.PackageManager = "dnf" + case commandExists("pacman"): + p.PackageManager = "pacman" + case commandExists("zypper"): + p.PackageManager = "zypper" + case commandExists("apk"): + p.PackageManager = "apk" + default: + p.PackageManager = "unknown" + } + p.Desktop = envOr("XDG_CURRENT_DESKTOP", envOr("DESKTOP_SESSION", "")) + p.Session = envOr("XDG_SESSION_TYPE", "") + if commandExists("systemctl") { + cmd := exec.Command("systemctl", "--user", "show-environment") + p.SystemdUser = cmd.Run() == nil + } + p.Omarchy = commandExists("omarchy") + return p +} diff --git a/backend/cmd/citizen-launcher/stack.go b/backend/cmd/citizen-launcher/stack.go new file mode 100644 index 0000000..e7fba57 --- /dev/null +++ b/backend/cmd/citizen-launcher/stack.go @@ -0,0 +1,826 @@ +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) + 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() + 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) { + // 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 := 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", "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 + } + 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 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.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", "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, "citizen-launcher") + 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 Citizen Launcher +Type=Application +Categories=Game; +Terminal=false +StartupNotify=true +StartupWMClass=rsi launcher.exe +Exec=` + script + `\n` + if err := os.WriteFile(filepath.Join(apps, "citizen-launcher-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() + // On non-systemd distributions (or a stopped user timer), maintenance still + // happens opportunistically when the player launches the game. Never block + // the launch on network/update work. + if cfg := a.loadConfig(); cfg.AutoMaintain { + stale := true + if t, err := time.Parse(time.RFC3339, cfg.LastMaintenance); err == nil { + stale = time.Since(t) > 6*time.Hour + } + if stale && a.selfPath != "" { + _ = exec.Command(a.selfPath, "maintain").Start() + } + } + 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 diff --git a/backend/cmd/citizen-launcher/support.go b/backend/cmd/citizen-launcher/support.go new file mode 100644 index 0000000..296c372 --- /dev/null +++ b/backend/cmd/citizen-launcher/support.go @@ -0,0 +1,130 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "regexp" + "strings" + "time" +) + +func (a *App) createSupportBundle() (string, error) { + stamp := time.Now().Format("20060102-150405") + id := fmt.Sprintf("CL-%s-%d", stamp, os.Getpid()) + downloads := filepath.Join(a.home, "Downloads") + if out, err := exec.Command("xdg-user-dir", "DOWNLOAD").Output(); err == nil && strings.TrimSpace(string(out)) != "" { + downloads = strings.TrimSpace(string(out)) + } + if err := os.MkdirAll(downloads, 0o755); err != nil { + return "", err + } + target := filepath.Join(downloads, "Citizen-Launcher-Support-"+id+".tar.gz") + f, err := os.Create(target) + if err != nil { + return "", err + } + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + closeAll := func() { _ = tw.Close(); _ = gz.Close(); _ = f.Close() } + defer closeAll() + + add := func(name string, data []byte) error { + data = sanitizeSupport(data, a.home) + h := &tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: time.Now()} + if err := tw.WriteHeader(h); err != nil { + return err + } + _, err := tw.Write(data) + return err + } + addJSON := func(name string, v any) { + b, _ := json.MarshalIndent(v, "", " ") + b = append(b, '\n') + _ = add(name, b) + } + addJSON("platform.json", detectPlatform()) + addJSON("game-status.json", a.gameStatus()) + if st, _ := a.status(false); true { + addJSON("launcher-status.json", st) + } + addJSON("game-config.json", a.loadGameConfig()) + _ = add("README.txt", []byte("Citizen Launcher support bundle\nID: "+id+"\nPersonal paths, email/IP/MAC and common tokens are redacted on a best-effort basis.\n")) + + for name, cmd := range map[string][]string{ + "uname.txt": {"uname", "-a"}, + "os-release.txt": {"cat", "/etc/os-release"}, + "memory.txt": {"free", "-h"}, + "disk.txt": {"df", "-hT"}, + "pci.txt": {"lspci", "-nnk"}, + "vulkan.txt": {"vulkaninfo", "--summary"}, + "systemd-timer.txt": {"systemctl", "--user", "status", "citizen-launcher-maintenance.timer", "--no-pager"}, + } { + if _, err := exec.LookPath(cmd[0]); err != nil { + continue + } + out, _ := exec.Command(cmd[0], cmd[1:]...).CombinedOutput() + _ = add(name, out) + } + for name, path := range map[string]string{ + "launcher.log": a.logPath, + "rsi-launcher.log": filepath.Join(a.stateDir, "rsi-launcher.log"), + } { + if b, err := tailFile(path, 700000); err == nil { + _ = add(name, b) + } + } + return target, nil +} + +func tailFile(path string, max int64) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + st, err := f.Stat() + if err != nil { + return nil, err + } + off := int64(0) + if st.Size() > max { + off = st.Size() - max + } + if _, err = f.Seek(off, 0); err != nil { + return nil, err + } + var b bytes.Buffer + _, err = b.ReadFrom(f) + return b.Bytes(), err +} + +func sanitizeSupport(in []byte, home string) []byte { + s := string(in) + if home != "" { + s = strings.ReplaceAll(s, home, "~") + } + if u, err := user.Current(); err == nil && u.Username != "" { + s = regexp.MustCompile(`(?i)\\b`+regexp.QuoteMeta(u.Username)+`\\b`).ReplaceAllString(s, "") + } + if h, _ := os.Hostname(); h != "" { + s = strings.ReplaceAll(s, h, "") + } + patterns := []struct{ re, repl string }{ + {`(?i)\\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}\\b`, ""}, + {`\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b`, ""}, + {`(?i)\\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\\b`, ""}, + {`(?i)Bearer\\s+[A-Za-z0-9._~+/=-]+`, "Bearer "}, + {`(?i)(token|secret|password|passwd|cookie|session)\\s*[:=]\\s*[^\\s,;]+`, "$1="}, + } + for _, p := range patterns { + s = regexp.MustCompile(p.re).ReplaceAllString(s, p.repl) + } + return []byte(s) +} diff --git a/backend/cmd/citizen-launcher/web/app.js b/backend/cmd/citizen-launcher/web/app.js new file mode 100644 index 0000000..036a012 --- /dev/null +++ b/backend/cmd/citizen-launcher/web/app.js @@ -0,0 +1 @@ +const B=window.CITIZEN_BASE;let current=null;const $=id=>document.getElementById(id);async function api(path,opts){const r=await fetch(B+path,opts);if(!r.ok)throw new Error(await r.text());return r.json()}function cls(el,state){el.classList.remove('good','bad','warn');if(state)el.classList.add(state)}function txt(id,v,c){const e=$(id);e.textContent=v||'—';cls(e,c)}function primaryFor(h){if(h==='ready')return['STAR CITIZEN STARTEN','launch'];if(h==='hardware-blocked')return['HARDWARE-DETAILS','doctor'];if(h==='repair'||h==='launcher-repair')return['AUTOMATISCH REPARIEREN','repair'];return['EINRICHTEN & STARTKLAR MACHEN','setup']}async function refresh(){try{const s=await api('/api/status');current=s;$('version').textContent='v'+s.version;$('platform').textContent=[s.platform.name,s.platform.desktop].filter(Boolean).join(' · ');$('distro').textContent=s.platform.name;$('session').textContent=s.platform.session||'—';const g=s.game;let head='READY FOR SETUP',sub='Citizen Launcher übernimmt Wine, DXVK, Prefix und RSI Launcher.';if(g.health==='ready'){head='FLIGHT READY';sub='Star Citizen ist startbereit. Autopilot hält den Gaming-Stack aktuell.'}else if(g.health==='hardware-blocked'){head='HARDWARE CHECK';sub=g.hardware_reason||'Die Hardware erfüllt eine Voraussetzung nicht.'}else if(g.health==='repair'||g.health==='launcher-repair'){head='REPAIR AVAILABLE';sub='Ein reparierbarer Zustand wurde erkannt.'}$('headline').textContent=head;$('subline').textContent=sub;const [label,act]=primaryFor(g.health);$('primary').textContent=label;$('primary').dataset.action=act;txt('gpu',g.gpu||'nicht eindeutig erkannt',g.hardware==='blocked'?'bad':'good');txt('vulkan',g.vulkan,g.vulkan==='blocked'?'bad':g.vulkan==='ready'?'good':'warn');txt('ram',g.ram_gib+' GiB',g.ram_gib>=16?'good':'warn');txt('wine',g.wine_version||'wird eingerichtet',g.wine_version?'good':'warn');txt('dxvk',g.dxvk_version||'wird eingerichtet',g.dxvk_version?'good':'warn');txt('rsi',g.launcher_state==='ready'?'bereit':'Setup',g.launcher_state==='ready'?'good':'warn');txt('autopilot',g.autopilot?'aktiv':'aus',g.autopilot?'good':'warn');$('raw').textContent=JSON.stringify(s,null,2)}catch(e){$('headline').textContent='BACKEND ERROR';$('subline').textContent=e.message}}async function action(name){try{if(name==='launch'){await api('/api/action/launch',{method:'POST'});return}const j=await api('/api/action/'+name,{method:'POST'});$('job').classList.remove('hidden');$('jobTitle').textContent='Aktion: '+name;$('jobText').textContent='läuft …';poll(j.id)}catch(e){$('job').classList.remove('hidden');$('jobTitle').textContent='Fehler';$('jobText').textContent=e.message}}async function poll(id){try{const j=await api('/api/job/'+id);if(j.state==='running'){setTimeout(()=>poll(id),1000);return}$('jobText').textContent=j.state==='done'?(j.message||'Abgeschlossen'):j.error;$('job').querySelector('.spinner').classList.toggle('hidden',j.state!=='running');await refresh()}catch(e){$('jobText').textContent=e.message}}document.addEventListener('click',e=>{const a=e.target.dataset.action;if(a)action(a);const o=e.target.dataset.open;if(o)api('/api/open/'+o,{method:'POST'});});$('toggle').onclick=()=>$('advanced').classList.toggle('hidden');$('primary').onclick=()=>action($('primary').dataset.action);refresh();setInterval(refresh,10000); \ No newline at end of file diff --git a/backend/cmd/citizen-launcher/web/index.html b/backend/cmd/citizen-launcher/web/index.html new file mode 100644 index 0000000..732be6c --- /dev/null +++ b/backend/cmd/citizen-launcher/web/index.html @@ -0,0 +1,16 @@ + + +Citizen Launcher +
+
✦
LINUX FLIGHT SYSTEM

Citizen Launcher

Linux wird erkannt …

+
SYSTEM STATE

SYSTEM CHECK

Hardware und Gaming-Stack werden geprüft.

+
+

Hardware

GPU—
Vulkan—
RAM—
+

Gaming Stack

Wine—
DXVK—
Launcher—
+

Autopilot

Wartung—
Distribution—
Session—
+
+
+ +
+
Citizen Launcher verwaltet Star Citizen im Benutzerkonto. System-/GPU-Treiber bleiben Aufgabe der Distribution.
+
\ No newline at end of file diff --git a/backend/cmd/citizen-launcher/web/style.css b/backend/cmd/citizen-launcher/web/style.css new file mode 100644 index 0000000..8d9a3ef --- /dev/null +++ b/backend/cmd/citizen-launcher/web/style.css @@ -0,0 +1 @@ +:root{color-scheme:dark;--bg:#070d13;--panel:#0b1822;--panel2:#0f2230;--line:#1d526d;--cyan:#55d8ff;--green:#75e9b1;--amber:#ffd275;--red:#ff879c;--text:#e8f5fb;--muted:#8da7b5}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 70% -20%,#123047 0,transparent 42%),linear-gradient(150deg,#050a10,#09131c 60%,#06101a);color:var(--text);font:15px Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}.scan{position:fixed;inset:0;pointer-events:none;opacity:.12;background:repeating-linear-gradient(0deg,transparent 0 3px,#66dbff10 4px)}main{width:min(1180px,calc(100% - 32px));margin:28px auto 48px}header{display:flex;align-items:center;gap:16px;padding:0 4px 20px}.mark{width:54px;height:54px;display:grid;place-items:center;border:1px solid var(--cyan);border-radius:14px;color:var(--cyan);font-size:25px;box-shadow:0 0 25px #4bd5ff22 inset}.eyebrow{font-size:11px;letter-spacing:.18em;color:var(--cyan);font-weight:800}h1,h2,h3,p{margin:0}h1{font-size:25px;letter-spacing:.02em}header p{color:var(--muted);margin-top:3px}.version{margin-left:auto;color:var(--muted);font-family:ui-monospace,monospace}.hero,.grid article,.job,.details{border:1px solid #1c4053;border-radius:20px;background:linear-gradient(155deg,#0c1b26e8,#09131be8);box-shadow:0 18px 60px #0007}.hero{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:28px;border-color:#2c7798}.hero h2{font-size:35px;letter-spacing:.04em;margin:5px 0 7px}.hero p{color:var(--muted);max-width:690px}.primary{padding:17px 28px!important;font-weight:900;letter-spacing:.08em;border-color:var(--cyan)!important;color:#08141c!important;background:linear-gradient(135deg,#7de4ff,#45c8f7)!important;min-width:270px}.grid{display:grid;gap:14px;margin-top:14px}.three{grid-template-columns:repeat(3,1fr)}article{padding:20px}article h3{color:var(--cyan);font-size:13px;letter-spacing:.12em;text-transform:uppercase;margin-bottom:12px}.metric{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #153345}.metric:last-child{border:0}.metric span{color:var(--muted)}.metric b{text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:65%}.actions{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:14px 0}button{border:1px solid #2d5368;background:#0d1b25;color:var(--text);padding:13px 16px;border-radius:10px;cursor:pointer;font:inherit;transition:.15s}button:hover{border-color:var(--cyan);background:#102737}.job{display:flex;align-items:center;gap:14px;padding:16px;margin:14px 0}.job p{color:var(--muted);margin-top:3px}.spinner{width:22px;height:22px;border:2px solid #285165;border-top-color:var(--cyan);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.details{padding:12px 14px}.details>button{width:100%;background:transparent;border:0;color:var(--muted)}pre{white-space:pre-wrap;color:#a9c7d4;background:#061018;padding:14px;border-radius:12px;max-height:330px;overflow:auto;font-size:12px}.small-actions{display:flex;gap:8px}.hidden{display:none!important}.bad{color:var(--red)!important}.warn{color:var(--amber)!important}.good{color:var(--green)!important}footer{text-align:center;color:#597584;font-size:12px;padding:22px}@media(max-width:850px){.three,.actions{grid-template-columns:1fr}.hero{align-items:stretch;flex-direction:column}.primary{width:100%}} \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index 1e21382..fcde4fa 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,3 @@ -module omarchy-citizen/backend +module citizen-launcher/backend go 1.23 diff --git a/backend/integration-test.sh b/backend/integration-test.sh index d3f462d..e00c008 100644 --- a/backend/integration-test.sh +++ b/backend/integration-test.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT="$(cd -- "$(dirname -- "$0")" && pwd)" -BACK="$ROOT/bin/omarchy-citizen-backend" +BACK="$ROOT/bin/citizen-launcher" TEST="$(mktemp -d -t omarchy-citizen-updater.XXXXXX)" trap 'rm -rf "$TEST"' EXIT @@ -59,7 +59,7 @@ git -C "$TEST/publisher" push -q git -C "$HOME/.config/omarchy/plugins/local.omarchy-citizen" remote set-url origin "$TEST/changed.git" set +e -CHECK_OUTPUT="$("$HOME/.local/lib/omarchy-citizen/omarchy-citizen-backend" check 2>&1)" +CHECK_OUTPUT="$("$HOME/.local/lib/citizen-launcher/citizen-launcher" check 2>&1)" RC=$? set -e [[ "$RC" -ne 0 ]] diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..0190e9d --- /dev/null +++ b/build.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +(cd "$ROOT/backend" && ./build.sh) +echo "Build fertig: backend/bin/citizen-launcher" diff --git a/dist/citizen-launcher-0.9.0-linux-amd64.tar.gz b/dist/citizen-launcher-0.9.0-linux-amd64.tar.gz new file mode 100644 index 0000000..bc32886 Binary files /dev/null and b/dist/citizen-launcher-0.9.0-linux-amd64.tar.gz differ diff --git a/dist/citizen-launcher_0.9.0_amd64.deb b/dist/citizen-launcher_0.9.0_amd64.deb new file mode 100644 index 0000000..2e9eda5 Binary files /dev/null and b/dist/citizen-launcher_0.9.0_amd64.deb differ diff --git a/install-omarchy.sh b/install-omarchy.sh new file mode 100644 index 0000000..2fc502b --- /dev/null +++ b/install-omarchy.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +"$ROOT/install.sh" +command -v omarchy >/dev/null 2>&1 || { echo "Omarchy nicht gefunden; Standalone-Launcher ist trotzdem installiert."; exit 0; } +DEST="$HOME/.config/omarchy/plugins/local.omarchy-citizen" +mkdir -p "$DEST/backend/bin" +cp -f "$ROOT/integrations/omarchy/manifest.json" "$DEST/manifest.json" +cp -f "$ROOT/integrations/omarchy/BarWidget.qml" "$DEST/BarWidget.qml" +cp -f "$ROOT/integrations/omarchy/Panel.qml" "$DEST/Panel.qml" +cp -f "$ROOT/integrations/omarchy/citizenctl" "$DEST/citizenctl" +cp -f "$ROOT/integrations/omarchy/support-sanitize.py" "$DEST/support-sanitize.py" +cp -f "$ROOT/backend/bin/citizen-launcher" "$DEST/backend/bin/citizen-launcher" +chmod +x "$DEST/citizenctl" "$DEST/backend/bin/citizen-launcher" +omarchy plugin validate "$DEST" +omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true +omarchy plugin enable local.omarchy-citizen >/dev/null 2>&1 || true +omarchy bar move local.omarchy-citizen --section right >/dev/null 2>&1 || true +omarchy restart shell >/dev/null 2>&1 || true +echo "Citizen Launcher + Omarchy Integration installiert." diff --git a/install.sh b/install.sh index 0e998b1..ede0c05 100644 --- a/install.sh +++ b/install.sh @@ -1,60 +1,26 @@ #!/usr/bin/env bash set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +BIN_SRC="$ROOT/backend/bin/citizen-launcher" +BIN_DIR="$HOME/.local/bin" +LIB_DIR="$HOME/.local/lib/citizen-launcher" +APP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" -PLUGIN_ID="local.omarchy-citizen" -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -DEST="$HOME/.config/omarchy/plugins/$PLUGIN_ID" - -if ! command -v omarchy >/dev/null 2>&1; then - echo "Fehler: 'omarchy' wurde nicht gefunden." - echo "Dieses Plugin benötigt Omarchy Quattro mit dem Plugin-System." - exit 1 -fi - -mkdir -p "$DEST" - -for file in manifest.json BarWidget.qml Panel.qml citizenctl support-sanitize.py README.md INSTALLATION.md LICENSE uninstall.sh install.sh INSTALLIEREN.sh; do - src="$SCRIPT_DIR/$file" - dst="$DEST/$file" - if [[ "$(readlink -f "$src")" != "$(readlink -f "$dst" 2>/dev/null || printf '%s' "$dst")" ]]; then - cp -f -- "$src" "$dst" - fi -done - -if [[ "$(readlink -f "$SCRIPT_DIR/backend")" != "$(readlink -f "$DEST/backend" 2>/dev/null || printf '%s' "$DEST/backend")" ]]; then - rm -rf -- "$DEST/backend" - cp -a -- "$SCRIPT_DIR/backend" "$DEST/backend" -fi - -chmod +x "$DEST/citizenctl" "$DEST/support-sanitize.py" "$DEST/uninstall.sh" "$DEST/INSTALLIEREN.sh" "$DEST/backend/bin/omarchy-citizen-backend" - -echo "Validiere Plugin..." -omarchy plugin validate "$DEST" - -echo "Lade Plugin neu..." -omarchy-shell shell rescanPlugins - -echo "Aktiviere Plugin..." -"$DEST/backend/bin/omarchy-citizen-backend" self-sync >/dev/null 2>&1 || true -echo "Aktiviere Rundum-Sorglos Autopilot …" -"$HOME/.local/lib/omarchy-citizen/omarchy-citizen-backend" autopilot enable || { - echo "Hinweis: Autopilot konnte beim Installer noch nicht vollständig synchronisieren." - echo "Der SC-Setup-Button versucht es erneut und protokolliert die Ursache." -} - -omarchy plugin enable "$PLUGIN_ID" - -# QML hot-reload is currently unreliable for third-party bar plugins. -omarchy restart shell >/dev/null 2>&1 || true - -echo -echo "Omarchy Citizen 0.8.1 wurde installiert:" -echo " $DEST" -echo -echo "Links-Klick auf 'SC' in der Omarchy-Leiste öffnet das Star-Citizen-Panel." -echo -echo "Falls 'SC' nicht sichtbar ist:" -echo " omarchy bar move $PLUGIN_ID --section right" -echo -echo "Omarchy Citizen Status:" -bash "$DEST/citizenctl" status +[[ -x "$BIN_SRC" ]] || { echo "Backend fehlt. Zuerst ./build.sh ausführen." >&2; exit 1; } +mkdir -p "$BIN_DIR" "$LIB_DIR" "$APP_DIR" +install -m755 "$BIN_SRC" "$BIN_DIR/citizen-launcher" +install -m755 "$BIN_SRC" "$LIB_DIR/citizen-launcher" +cat > "$APP_DIR/io.github.citizenlauncher.CitizenLauncher.desktop" </dev/null 2>&1; then update-desktop-database "$APP_DIR" >/dev/null 2>&1 || true; fi +echo "Citizen Launcher installiert. Starte ihn über das App-Menü oder: citizen-launcher gui" diff --git a/integrations/omarchy/BarWidget.qml b/integrations/omarchy/BarWidget.qml new file mode 100644 index 0000000..0d2e474 --- /dev/null +++ b/integrations/omarchy/BarWidget.qml @@ -0,0 +1,69 @@ +import QtQuick +import Quickshell +import qs.Ui + +BarWidget { + id: root + moduleName: "local.omarchy-citizen" + + readonly property bool opened: panelLoader.item + ? panelLoader.item.opened === true + : false + + readonly property bool popoutSwitchClosing: panelLoader.item + ? panelLoader.item.popoutSwitchClosing === true + : false + + function open() { + if (panelLoader.item) panelLoader.item.open() + } + + function close() { + if (panelLoader.item) panelLoader.item.close() + } + + function toggle() { + if (panelLoader.item) panelLoader.item.toggle() + } + + function closeForPopoutSwitch() { + if (panelLoader.item) panelLoader.item.closeForPopoutSwitch() + } + + function injectPanel() { + if (!panelLoader.item) return + panelLoader.item.bar = root.bar + panelLoader.item.anchorItem = button + panelLoader.item.hostWidget = root + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + onBarChanged: injectPanel() + + Loader { + id: panelLoader + active: true + source: Qt.resolvedUrl("Panel.qml") + visible: false + + onLoaded: { + root.injectPanel() + Qt.callLater(root.injectPanel) + } + } + + WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: "✦SC" + tooltipText: "Omarchy Citizen – Plug & Play Star Citizen" + onPressed: function(buttonCode) { + if (buttonCode === Qt.LeftButton) root.toggle() + else if (buttonCode === Qt.RightButton && root.bar) + root.bar.run("xdg-open https://wiki.starcitizen-lug.org") + } + } +} diff --git a/integrations/omarchy/Panel.qml b/integrations/omarchy/Panel.qml new file mode 100644 index 0000000..901af23 --- /dev/null +++ b/integrations/omarchy/Panel.qml @@ -0,0 +1,972 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui + +Panel { + id: root + moduleName: "local.omarchy-citizen" + manageIpc: false + + property var anchorItem: null + property var hostWidget: null + + property string pluginVersion: "0.9.0" + property string health: "checking" + property string depsState: "checking" + property string depsMissing: "" + property string helperState: "checking" + property string helperManaged: "missing" + property string helperVersion: "" + property string updateState: "unknown" + property string updateVersion: "" + property string prefixState: "checking" + property string prefixDetail: "" + property string launcherState: "checking" + property string gameState: "checking" + property bool checkingUpdate: false + property bool advancedVisible: false + property string backendState: "missing" + property string backendVersion: "" + property string pluginManaged: "manual" + property string pluginAuto: "false" + property string pluginUpdate: "unavailable" + property string pluginLocalCommit: "" + property string pluginRemoteCommit: "" + property string autopilot: "false" + property string managedLUGVersion: "" + property string managedWineVersion: "" + property string managedDXVKVersion: "" + property string maintenanceResult: "" + property string hardwareState: "unknown" + property string hardwareReason: "" + property string hardwareGPU: "" + property string hardwareVulkan: "unknown" + property string actionStatus: "" + property string actionError: "" + property string actionKind: "" + property bool actionCloseOnSuccess: true + + readonly property color accent: "#56d2ff" + readonly property color accentSoft: "#1c3444" + readonly property color surface: "#0d1822" + readonly property color surfaceRaised: "#122230" + readonly property color border: "#2b485c" + readonly property color success: "#71e7ad" + readonly property color warning: "#ffd276" + readonly property color danger: "#ff8fa1" + readonly property color muted: "#a8bbc9" + readonly property string uiFont: "sans-serif" + + readonly property string controlScript: decodeURIComponent( + Qt.resolvedUrl("citizenctl").toString().replace(/^file:\/\//, "") + ) + + readonly property bool canLaunch: health === "ready" + + function open() { root.controller.show() } + function close() { root.controller.hide() } + + function switchPanel(direction) { + if (root.bar && typeof root.bar.switchPanelFrom === "function") + return root.bar.switchPanelFrom(root.hostWidget || root, direction) + return false + } + + function runAction(action, closePanel) { + if (actionProc.running) return + + root.actionKind = String(action || "") + root.actionCloseOnSuccess = closePanel !== false + root.actionError = "" + root.actionStatus = "Aktion wird gestartet…" + + actionProc.command = ["bash", root.controlScript, root.actionKind] + actionProc.running = true + } + + function refreshStatus() { + if (!statusProc.running) statusProc.running = true + } + + function checkForUpdates() { + if (updateProc.running) return + root.checkingUpdate = true + root.updateState = "checking" + updateProc.running = true + } + + function applyStatus(raw) { + var values = {} + var lines = String(raw || "").trim().split("\n") + for (var i = 0; i < lines.length; i++) { + var p = lines[i].indexOf("=") + if (p <= 0) continue + values[lines[i].slice(0, p)] = lines[i].slice(p + 1) + } + + pluginVersion = values.plugin_version || "0.9.0" + health = values.health || "setup" + depsState = values.deps || "missing" + depsMissing = values.deps_missing || "" + helperState = values.helper || "missing" + helperManaged = values.helper_managed || "missing" + helperVersion = values.helper_version || "" + updateState = values.update || "unknown" + updateVersion = values.update_version || "" + prefixState = values.prefix || "missing" + prefixDetail = values.prefix_detail || "" + launcherState = values.launcher || "missing" + gameState = values.game || "missing" + backendState = values.backend || "missing" + backendVersion = values.backend_version || "" + pluginManaged = values.plugin_managed || "manual" + pluginAuto = values.plugin_auto || "false" + pluginUpdate = values.plugin_update || "unavailable" + pluginLocalCommit = values.plugin_local_commit || "" + pluginRemoteCommit = values.plugin_remote_commit || "" + autopilot = values.autopilot || "false" + managedLUGVersion = values.managed_lug_version || "" + managedWineVersion = values.managed_wine_version || "" + managedDXVKVersion = values.managed_dxvk_version || "" + maintenanceResult = values.maintenance_result || "" + hardwareState = values.hardware || "unknown" + hardwareReason = values.hardware_reason || "" + hardwareGPU = values.hardware_gpu || "" + hardwareVulkan = values.hardware_vulkan || "unknown" + } + + function stateIcon(state) { + if (state === "ready" || state === "current") return "✓" + if (state === "available") return "↑" + if (state === "warning") return "!" + if (state === "partial" || state === "unknown") return "…" + if (state === "checking") return "·" + return "!" + } + + function stateColor(state) { + if (state === "ready" || state === "current") return success + if (state === "available" || state === "warning") return warning + if (state === "partial" || state === "unknown" || state === "checking") return accent + return danger + } + + function pluginUpdateText() { + if (root.pluginUpdate === "available") return "Update verfügbar" + if (root.pluginUpdate === "current") return "aktuell" + if (root.pluginUpdate === "blocked-dirty") return "lokale Änderungen · Update pausiert" + if (root.pluginUpdate === "blocked-diverged") return "Git-Stand abweichend · Update pausiert" + if (root.pluginUpdate === "blocked-remote") return "Updatequelle geändert · Update pausiert" + if (root.pluginUpdate === "zip") return "ZIP-Installation" + return root.pluginUpdate + } + + function pluginUpdateState() { + if (root.pluginUpdate === "available") return "available" + if (root.pluginUpdate === "current") return "current" + if (String(root.pluginUpdate).indexOf("blocked-") === 0) return "warning" + return "unknown" + } + + function healthColor() { + if (health === "hardware-blocked") return danger + if (health === "ready") return success + if (health === "recover-prefix" || health === "repair") return warning + if (health === "checking") return accent + return accent + } + + function headline() { + if (health === "hardware-blocked") return "HARDWARE NICHT SPIELBEREIT" + if (health === "ready" && root.autopilot === "true") return "FLIGHT READY · AUTOPILOT" + if (health === "ready") return "FLIGHT READY" + if (health === "recover-prefix") return "SETUP REPAIR" + if (health === "repair") return "REPAIR AVAILABLE" + if (health === "checking") return "SYSTEM CHECK" + return "READY FOR SETUP" + } + + function subline() { + if (health === "hardware-blocked") + return root.hardwareReason !== "" ? root.hardwareReason : "Eine für Star Citizen geeignete Vulkan-GPU wurde nicht erkannt." + if (health === "ready") + return "Wine, DXVK und RSI Launcher sind geprüft. Star Citizen ist einen Klick entfernt." + if (health === "install-game") + return "Der Software-Stack ist bereit. Melde dich im offiziellen RSI Launcher an und starte dort den Spieldownload." + if (health === "repair" || health === "launcher-repair") + return "Omarchy Citizen kann den gesamten verwalteten Gaming-Stack automatisch reparieren." + return "Omarchy Citizen wählt selbst einen kompatiblen Wine-Runner, erstellt den Prefix, installiert DXVK/Komponenten und den offiziellen RSI Launcher." + } + + function primaryText() { + if (health === "hardware-blocked") return "ⓘ HARDWARE-DETAILS" + if (health === "ready") return "▶ STAR CITIZEN STARTEN" + if (health === "install-game") return "▶ RSI LAUNCHER ÖFFNEN" + if (health === "repair" || health === "launcher-repair") return "↻ AUTOMATISCH REPARIEREN" + return "▶ EINRICHTEN & STARTKLAR MACHEN" + } + + function updateText() { + if (checkingUpdate || updateState === "checking") return "wird geprüft" + if (helperManaged === "external") return "extern" + if (helperState !== "ready") return "nach Setup" + if (updateState === "available") + return updateVersion !== "" ? "Update " + updateVersion : "Update verfügbar" + if (updateState === "current") return "aktuell" + return "noch nicht geprüft" + } + + onOpenedChanged: if (opened) refreshStatus() + + Process { + id: statusProc + command: ["bash", root.controlScript, "status"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.applyStatus(text) + } + } + + Process { + id: updateProc + command: ["bash", root.controlScript, "check-update"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + root.checkingUpdate = false + root.refreshStatus() + } + } + } + + Process { + id: actionProc + running: false + command: [] + + stdout: StdioCollector { + id: actionOut + waitForEnd: true + } + + stderr: StdioCollector { + id: actionErr + waitForEnd: true + } + + onStarted: { + if (root.actionKind === "primary" && root.health === "recover-prefix") + root.actionStatus = "Reparatur-Fenster wird geöffnet…" + else if (root.actionKind === "primary") + root.actionStatus = "Setup/Start wird vorbereitet…" + else + root.actionStatus = "Aktion wird ausgeführt…" + } + + onExited: function(exitCode) { + if (exitCode === 0) { + root.actionError = "" + root.actionStatus = root.actionKind === "primary" && root.health === "install-game" + ? "RSI Launcher wurde gestartet." + : (root.actionKind === "primary" && root.health !== "ready" + ? "Setup-Fenster wurde geöffnet. Omarchy Citizen übernimmt die technischen Schritte." + : "Aktion wurde gestartet.") + root.refreshStatus() + + if (root.actionCloseOnSuccess) + closeAfterAction.restart() + } else { + var detail = String(actionErr.text || "").trim() + root.actionStatus = "" + root.actionError = detail !== "" + ? detail + : "Die Aktion konnte nicht gestartet werden. Bitte erstelle ein Support-Paket." + } + } + } + + Timer { + id: closeAfterAction + interval: 350 + repeat: false + onTriggered: root.close() + } + + Timer { + interval: 10000 + repeat: true + running: root.opened + onTriggered: root.refreshStatus() + } + + KeyboardPanel { + id: panel + anchorItem: root.anchorItem + owner: root.hostWidget || root + bar: root.bar + open: root.opened + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(450)) + contentHeight: panel.fittedContentHeight(content.implicitHeight) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + onCloseRequested: root.close() + onTabRequested: function(direction) { root.switchPanel(direction) } + + Column { + id: content + width: parent.width + spacing: Style.space(10) + + Rectangle { + width: parent.width + radius: 18 + color: root.surfaceRaised + border.color: root.healthColor() + border.width: 1 + implicitHeight: heroContent.implicitHeight + Style.space(24) + + Column { + id: heroContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.space(16) + spacing: Style.space(10) + + Row { + width: parent.width + spacing: Style.space(10) + + Rectangle { + width: Style.space(34) + height: Style.space(34) + radius: 10 + color: root.accentSoft + border.color: root.healthColor() + border.width: 1 + + Text { + anchors.centerIn: parent + text: "✦" + color: root.healthColor() + font.family: root.uiFont + font.pixelSize: Style.font.title + font.bold: true + } + } + + Column { + spacing: Style.space(2) + + Text { + text: "CITIZEN LAUNCHER" + color: root.barForeground + font.family: root.uiFont + font.pixelSize: Style.font.title + font.bold: true + } + + Text { + text: root.headline() + color: root.healthColor() + font.family: root.uiFont + font.pixelSize: Style.font.bodySmall + font.bold: true + } + } + } + + Text { + width: parent.width + text: root.subline() + color: root.muted + font.family: root.uiFont + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + } + + Button { + width: parent.width + text: actionProc.running + ? "… WIRD GESTARTET" + : root.primaryText() + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + active: root.health === "ready" || actionProc.running + onClicked: root.runAction( + root.health === "hardware-blocked" ? "hardware-info" : + ((root.health === "repair" || root.health === "launcher-repair") ? "repair" : "primary"), + root.health === "ready" + ) + } + + Text { + visible: root.actionStatus !== "" + width: parent.width + text: root.actionStatus + color: root.accent + font.family: root.uiFont + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + + Rectangle { + visible: root.actionError !== "" + width: parent.width + radius: 10 + color: Qt.rgba(root.danger.r, root.danger.g, root.danger.b, 0.10) + border.color: root.danger + border.width: 1 + implicitHeight: actionErrorText.implicitHeight + Style.space(16) + + Text { + id: actionErrorText + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.space(8) + text: root.actionError + color: root.danger + font.family: root.uiFont + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + } + } + + Rectangle { + width: parent.width + radius: 16 + color: root.surface + border.color: root.border + border.width: 1 + implicitHeight: simpleActions.implicitHeight + Style.space(20) + + Column { + id: simpleActions + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.space(14) + spacing: Style.space(9) + + Text { + text: "HILFE OHNE LINUX-WISSEN" + color: root.accent + font.family: root.uiFont + font.pixelSize: Style.font.bodySmall + font.bold: true + } + + Grid { + id: helpGrid + width: parent.width + columns: 2 + columnSpacing: Style.space(7) + rowSpacing: Style.space(7) + readonly property real cellWidth: (width - columnSpacing) / 2 + + Button { + width: helpGrid.cellWidth + text: "↻ Problem beheben" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("repair") + } + + Button { + width: helpGrid.cellWidth + text: "▣ Support-Paket erstellen" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("support") + } + } + + Text { + width: parent.width + text: "Das Support-Paket sammelt Diagnoseinformationen automatisch und maskiert typische persönliche Daten vor dem Export." + color: root.muted + font.family: root.uiFont + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + } + + Rectangle { + width: parent.width + radius: 16 + color: root.surface + border.color: root.border + border.width: 1 + implicitHeight: quickStatus.implicitHeight + Style.space(20) + + Column { + id: quickStatus + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.space(14) + spacing: Style.space(8) + + Row { + width: parent.width + spacing: Style.space(8) + + StatusChip { + label: "LUG" + value: root.helperState === "ready" ? "bereit" : "fehlt" + chipColor: root.stateColor(root.helperState) + } + + StatusChip { + label: "Launcher" + value: root.launcherState === "ready" ? "bereit" : "Setup" + chipColor: root.stateColor(root.launcherState) + } + + StatusChip { + label: "Game" + value: root.gameState === "ready" ? "LIVE" : "Setup" + chipColor: root.stateColor(root.gameState) + } + } + + Text { + width: parent.width + text: "Version " + root.pluginVersion + + " · Autopilot " + (root.autopilot === "true" ? "AKTIV" : "aus") + + " · LUG " + (root.managedLUGVersion !== "" ? root.managedLUGVersion : "—") + + " · Wine " + (root.managedWineVersion !== "" ? root.managedWineVersion : "—") + color: root.muted + font.family: root.uiFont + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + } + + Button { + width: parent.width + text: root.advancedVisible ? "▲ Erweiterte Optionen ausblenden" : "▼ Erweiterte Optionen anzeigen" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.advancedVisible = !root.advancedVisible + } + + Rectangle { + visible: root.advancedVisible + width: parent.width + radius: 16 + color: root.surface + border.color: root.border + border.width: 1 + implicitHeight: visible ? advancedContent.implicitHeight + Style.space(20) : 0 + + Column { + id: advancedContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.space(14) + spacing: Style.space(10) + + Text { + text: "ERWEITERTE OPTIONEN" + color: root.accent + font.family: root.uiFont + font.pixelSize: Style.font.bodySmall + font.bold: true + } + + StatusLine { + label: "GPU / Vulkan" + state: root.hardwareState === "blocked" ? "warning" : (root.hardwareState === "ready" ? "ready" : "unknown") + value: root.hardwareState === "blocked" ? "nicht spielbereit" : (root.hardwareGPU !== "" ? root.hardwareGPU : root.hardwareVulkan) + } + + StatusLine { + label: "Abhängigkeiten" + state: root.depsState + value: root.depsState === "ready" ? "bereit" : root.depsMissing + } + + StatusLine { + label: "Support-Helper" + state: root.helperState + value: root.helperState === "ready" + ? ((root.helperVersion !== "" ? root.helperVersion : "gefunden") + + " · optional") + : "nicht installiert" + } + + StatusLine { + label: "Wine-Prefix" + state: root.prefixState + value: root.prefixState === "ready" + ? "initialisiert" + : (root.prefixState === "partial" ? "unvollständig · Reparatur möglich" : "nicht eingerichtet") + } + + StatusLine { + label: "RSI Launcher" + state: root.launcherState + value: root.launcherState === "ready" ? "installiert" : "fehlt" + } + + PanelSeparator { foreground: root.barForeground } + + StatusLine { + label: "Autopilot" + state: root.autopilot === "true" ? "ready" : "warning" + value: root.autopilot === "true" + ? "aktiv · Wartung alle 6 Stunden" + : "nicht aktiviert" + } + + StatusLine { + label: "Managed Wine" + state: root.managedWineVersion !== "" ? "ready" : "unknown" + value: root.managedWineVersion !== "" ? root.managedWineVersion : "wird beim Setup geladen" + } + + StatusLine { + label: "Managed DXVK" + state: root.managedDXVKVersion !== "" ? "ready" : "unknown" + value: root.managedDXVKVersion !== "" ? root.managedDXVKVersion : "nach Prefix-Setup" + } + + Text { + text: "PLUGIN-UPDATES" + color: root.accent + font.family: root.uiFont + font.pixelSize: Style.font.bodySmall + font.bold: true + } + + StatusLine { + label: "Update-Modus" + state: root.backendState === "ready" ? "ready" : "missing" + value: root.pluginManaged === "git" + ? (root.pluginAuto === "true" ? "automatisch · alle ~6h" : "Git · manuell") + : "ZIP · Git-Installation für Auto-Updates nötig" + } + + StatusLine { + label: "Plugin-Stand" + state: root.pluginUpdateState() + value: root.pluginUpdateText() + } + + Grid { + id: pluginUpdateGrid + width: parent.width + columns: 2 + columnSpacing: Style.space(7) + rowSpacing: Style.space(7) + readonly property real cellWidth: (width - columnSpacing) / 2 + + Button { + width: pluginUpdateGrid.cellWidth + text: "Plugin-Update prüfen" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("plugin-update-check") + } + + Button { + width: pluginUpdateGrid.cellWidth + text: "Plugin jetzt aktualisieren" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + active: root.pluginUpdate === "available" + onClicked: root.runAction("plugin-update-now") + } + + Button { + width: pluginUpdateGrid.cellWidth + text: root.pluginAuto === "true" ? "Auto-Updates ausschalten" : "Auto-Updates einschalten" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction(root.pluginAuto === "true" ? "plugin-auto-disable" : "plugin-auto-enable") + } + + Button { + width: pluginUpdateGrid.cellWidth + text: "Updater-Log öffnen" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("updater-log") + } + } + + Text { + width: parent.width + text: root.pluginManaged === "git" + ? "Auto-Updates verwenden Omarchys eigenen Plugin-Updater mit Validierung und Rollback. Das beim Aktivieren vorhandene Git-Remote wird als vertrauenswürdige Quelle gespeichert." + : "Die aktuelle ZIP-Installation bleibt bewusst ohne unbekannte Update-Quelle. Sobald Omarchy Citizen aus einem Git-Repository installiert wird, erkennt das Backend dessen origin automatisch." + color: root.muted + font.family: root.uiFont + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + + PanelSeparator { foreground: root.barForeground } + + Grid { + id: setupGrid + width: parent.width + columns: 2 + columnSpacing: Style.space(7) + rowSpacing: Style.space(7) + readonly property real cellWidth: (width - columnSpacing) / 2 + + Button { + width: setupGrid.cellWidth + text: "Autopilot synchronisieren" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("autopilot-maintain") + } + + Button { + width: setupGrid.cellWidth + text: "Gaming-Stack aktualisieren" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("autopilot-maintain") + } + + Button { + width: setupGrid.cellWidth + text: "Omarchy aktualisieren" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("system-update") + } + + Button { + width: setupGrid.cellWidth + text: "Support-Helper öffnen" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("helper") + } + + Button { + width: setupGrid.cellWidth + text: "Wine-Selbsttest" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("wine-doctor") + } + + Button { + width: setupGrid.cellWidth + text: "DXVK automatisch prüfen" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("autopilot-maintain") + } + + Button { + width: setupGrid.cellWidth + text: "Gaming-Stack reparieren" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("repair") + } + + Button { + width: setupGrid.cellWidth + text: "Launcher-Dateien reparieren" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("repair") + } + + Button { + width: setupGrid.cellWidth + text: "Start-Log öffnen" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("log") + } + + Button { + width: setupGrid.cellWidth + text: "Plugin-Debuglog öffnen" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("debug-log") + } + } + + PanelSeparator { foreground: root.barForeground } + + Text { + text: "NGL · EXPERIMENTELL" + color: root.warning + font.family: root.uiFont + font.pixelSize: Style.font.bodySmall + font.bold: true + } + + Grid { + id: nglGrid + width: parent.width + columns: 2 + columnSpacing: Style.space(7) + rowSpacing: Style.space(7) + readonly property real cellWidth: (width - columnSpacing) / 2 + + Button { + width: nglGrid.cellWidth + text: "NGL Download" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("ngl-page") + } + + Button { + width: nglGrid.cellWidth + text: "NGL installieren" + foreground: root.barForeground + fontFamily: root.uiFont + bordered: true + onClicked: root.runAction("ngl-install") + } + } + + Text { + width: parent.width + text: "NGL bleibt bewusst optional. Der normale RSI/LUG-Weg funktioniert unabhängig davon." + color: root.warning + opacity: 0.82 + font.family: root.uiFont + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + } + + Text { + width: parent.width + text: "Ziel: spielen statt Linux administrieren. Für Support genügt normalerweise „Support-Paket erstellen“." + color: root.muted + font.family: root.uiFont + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + } + } + + component StatusChip: Rectangle { + id: chip + property string label: "" + property string value: "" + property color chipColor: root.accent + + radius: 999 + color: Qt.rgba(chip.chipColor.r, chip.chipColor.g, chip.chipColor.b, 0.12) + border.color: chip.chipColor + border.width: 1 + implicitWidth: chipRow.implicitWidth + Style.space(16) + implicitHeight: chipRow.implicitHeight + Style.space(9) + + Row { + id: chipRow + anchors.centerIn: parent + spacing: Style.space(5) + + Text { + text: chip.label + color: root.muted + font.family: root.uiFont + font.pixelSize: Style.font.caption + } + + Text { + text: chip.value + color: chip.chipColor + font.family: root.uiFont + font.pixelSize: Style.font.caption + font.bold: true + } + } + } + + component StatusLine: Rectangle { + id: statusLine + property string label: "" + property string value: "" + property string state: "unknown" + + width: parent.width + radius: 11 + color: root.surfaceRaised + border.color: root.border + border.width: 1 + implicitHeight: lineRow.implicitHeight + Style.space(12) + + Row { + id: lineRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Style.space(10) + spacing: Style.space(8) + + Rectangle { + width: 9 + height: 9 + radius: 5 + color: root.stateColor(statusLine.state) + } + + Text { + width: Style.space(118) + text: statusLine.label + color: root.muted + font.family: root.uiFont + font.pixelSize: Style.font.bodySmall + } + + Text { + width: Math.max(0, lineRow.width - Style.space(158)) + text: root.stateIcon(statusLine.state) + " " + statusLine.value + color: root.barForeground + font.family: root.uiFont + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + } + } + } +} diff --git a/integrations/omarchy/citizenctl b/integrations/omarchy/citizenctl new file mode 100644 index 0000000..5532e1e --- /dev/null +++ b/integrations/omarchy/citizenctl @@ -0,0 +1,519 @@ +#!/usr/bin/env bash +set -u + +PLUGIN_ID="local.omarchy-citizen" +PLUGIN_VERSION="0.9.0" + +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" + +PLUGIN_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SELF="$(readlink -f "${BASH_SOURCE[0]}")" +STATE_DIR="$XDG_STATE_HOME/citizen-launcher" +CACHE_DIR="$XDG_CACHE_HOME/citizen-launcher" +DATA_DIR="$XDG_DATA_HOME/citizen-launcher" +LOG_FILE="$STATE_DIR/omarchy-citizen.log" +UPDATER_LOG="$STATE_DIR/updater.log" +RSI_LOG="$STATE_DIR/rsi-launcher.log" +SANITIZER="$PLUGIN_DIR/support-sanitize.py" +BACKEND_EXTERNAL="$HOME/.local/lib/citizen-launcher/citizen-launcher" +BACKEND_BUNDLED="$PLUGIN_DIR/backend/bin/citizen-launcher" +MANAGED_LUG="$DATA_DIR/vendor/lug-helper/lug-helper.appimage" +MANAGED_WINE="$DATA_DIR/vendor/wine/current" + +mkdir -p "$STATE_DIR" "$CACHE_DIR" +ACTION="${1:-status}" + +rotate_log() { + local max=$((4 * 1024 * 1024)) keep=$((2 * 1024 * 1024)) size=0 + if [[ -f "$LOG_FILE" ]]; then + size="$(stat -c '%s' "$LOG_FILE" 2>/dev/null || echo 0)" + if (( size > max )); then + tail -c "$keep" "$LOG_FILE" > "$LOG_FILE.tmp" 2>/dev/null || true + mv -f "$LOG_FILE.tmp" "$LOG_FILE" 2>/dev/null || true + fi + fi +} + +log_line() { + printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$$" "$*" >>"$LOG_FILE" 2>/dev/null || true +} + +rotate_log +log_line "action=$ACTION version=$PLUGIN_VERSION" + +notify() { + local title="$1" body="$2" + if command -v omarchy-notification-send >/dev/null 2>&1; then + omarchy-notification-send "$title" "$body" >/dev/null 2>&1 || true + elif command -v notify-send >/dev/null 2>&1; then + notify-send "$title" "$body" >/dev/null 2>&1 || true + fi +} + +notify_error() { + local body="$1" + log_line "error=$body" + if command -v zenity >/dev/null 2>&1; then + zenity --error --width=560 --title="Citizen Launcher" --text="$body" >/dev/null 2>&1 || true + else + notify "Citizen Launcher" "$body" + printf 'Citizen Launcher: %s\n' "$body" >&2 + fi +} + +find_backend() { + if [[ -x "$BACKEND_EXTERNAL" ]]; then + printf '%s\n' "$BACKEND_EXTERNAL" + elif [[ -x "$BACKEND_BUNDLED" ]]; then + printf '%s\n' "$BACKEND_BUNDLED" + else + return 1 + fi +} + +backend_exec() { + local backend + backend="$(find_backend)" || { + printf 'Go-Backend nicht gefunden. Plugin bitte neu installieren.\n' >&2 + return 127 + } + "$backend" "$@" +} + +backend_game_status() { backend_exec game-status; } +backend_plugin_status() { backend_exec status; } +backend_game_install() { backend_exec game-install; } +backend_game_launch() { backend_exec game-launch; } +backend_game_repair() { backend_exec game-repair; } +autopilot_enable() { backend_exec autopilot enable; } +autopilot_maintain() { backend_exec maintain; } +wine_doctor() { backend_exec doctor; } + +kv_get() { + local key="$1" + sed -n "s/^${key}=//p" | head -n1 +} + +status() { + local game="" plugin="" + local health="setup" hardware="unknown" hardware_reason="" gpu="" vulkan="unknown" + local prefix="missing" launcher="missing" game_state="missing" wine="" dxvk="" lug="" game_autopilot="false" + local backend_state="missing" backend_version="" managed="manual" auto_apply="false" auto_maintain="false" + local update="unavailable" local_commit="" remote_commit="" maintenance="" + + if game="$(backend_game_status 2>/dev/null)"; then + health="$(printf '%s\n' "$game" | kv_get health)" + hardware="$(printf '%s\n' "$game" | kv_get hardware)" + hardware_reason="$(printf '%s\n' "$game" | kv_get hardware_reason)" + gpu="$(printf '%s\n' "$game" | kv_get gpu)" + vulkan="$(printf '%s\n' "$game" | kv_get vulkan)" + prefix="$(printf '%s\n' "$game" | kv_get prefix_state)" + launcher="$(printf '%s\n' "$game" | kv_get launcher_state)" + game_state="$(printf '%s\n' "$game" | kv_get game_state)" + wine="$(printf '%s\n' "$game" | kv_get wine_version)" + dxvk="$(printf '%s\n' "$game" | kv_get dxvk_version)" + lug="$(printf '%s\n' "$game" | kv_get lug_version)" + game_autopilot="$(printf '%s\n' "$game" | kv_get autopilot)" + fi + + if plugin="$(backend_plugin_status 2>/dev/null)"; then + backend_state="ready" + backend_version="$(printf '%s\n' "$plugin" | kv_get backend_version)" + managed="$(printf '%s\n' "$plugin" | kv_get managed)" + auto_apply="$(printf '%s\n' "$plugin" | kv_get auto_apply)" + auto_maintain="$(printf '%s\n' "$plugin" | kv_get auto_maintain)" + update="$(printf '%s\n' "$plugin" | kv_get update)" + local_commit="$(printf '%s\n' "$plugin" | kv_get local_commit)" + remote_commit="$(printf '%s\n' "$plugin" | kv_get remote_commit)" + maintenance="$(printf '%s\n' "$plugin" | kv_get maintenance_result)" + fi + + [[ -n "$health" ]] || health="setup" + [[ -n "$prefix" ]] || prefix="missing" + [[ -n "$launcher" ]] || launcher="missing" + [[ -n "$game_state" ]] || game_state="missing" + [[ -n "$hardware" ]] || hardware="unknown" + [[ -n "$vulkan" ]] || vulkan="unknown" + [[ -n "$update" ]] || update="unavailable" + + printf 'plugin_version=%s\n' "$PLUGIN_VERSION" + printf 'health=%s\n' "$health" + printf 'deps=ready\n' + printf 'deps_missing=\n' + printf 'helper=%s\n' "$([[ -n "$lug" ]] && echo ready || echo missing)" + printf 'helper_managed=optional\n' + printf 'helper_version=%s\n' "$lug" + printf 'update=unknown\n' + printf 'update_version=\n' + printf 'prefix=%s\n' "$prefix" + printf 'prefix_detail=%s\n' "$([[ "$prefix" == partial ]] && echo unvollständig || true)" + printf 'launcher=%s\n' "$launcher" + printf 'game=%s\n' "$game_state" + printf 'backend=%s\n' "$backend_state" + printf 'backend_version=%s\n' "$backend_version" + printf 'plugin_managed=%s\n' "$managed" + printf 'plugin_auto=%s\n' "$auto_apply" + printf 'plugin_update=%s\n' "$update" + printf 'plugin_local_commit=%s\n' "$local_commit" + printf 'plugin_remote_commit=%s\n' "$remote_commit" + printf 'autopilot=%s\n' "$([[ "$auto_maintain" == true ]] && echo true || echo "$game_autopilot")" + printf 'managed_lug_version=%s\n' "$lug" + printf 'managed_wine_version=%s\n' "$wine" + printf 'managed_dxvk_version=%s\n' "$dxvk" + printf 'maintenance_result=%s\n' "$maintenance" + printf 'hardware=%s\n' "$hardware" + printf 'hardware_reason=%s\n' "$hardware_reason" + printf 'hardware_gpu=%s\n' "$gpu" + printf 'hardware_vulkan=%s\n' "$vulkan" +} + +hardware_info() { + local game reason gpu vulkan + game="$(backend_game_status 2>/dev/null || true)" + reason="$(printf '%s\n' "$game" | kv_get hardware_reason)" + gpu="$(printf '%s\n' "$game" | kv_get gpu)" + vulkan="$(printf '%s\n' "$game" | kv_get vulkan)" + if command -v zenity >/dev/null 2>&1; then + zenity --warning --width=620 --title="Omarchy Citizen – Hardware" \ + --text="Star Citizen ist auf diesem System noch nicht spielbereit.\n\nGPU:\n${gpu:-nicht erkannt}\n\nVulkan: ${vulkan:-unbekannt}\n\n${reason:-Hardware-Prüfung fehlgeschlagen.}" >/dev/null 2>&1 || true + else + printf 'GPU: %s\nVulkan: %s\n%s\n' "${gpu:-nicht erkannt}" "${vulkan:-unbekannt}" "${reason:-Hardware-Prüfung fehlgeschlagen.}" + fi +} + +probe_detached_start() { + local label="$1"; shift + "$@" >>"$LOG_FILE" 2>&1 & + local pid=$! rc=0 + sleep 0.35 + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" || rc=$? + if (( rc != 0 )); then + log_line "$label failed rc=$rc" + return "$rc" + fi + else + disown "$pid" 2>/dev/null || true + fi + log_line "$label accepted" +} + +launch_terminal_action() { + local action="$1" + log_line "launch-terminal action=$action" + if command -v uwsm-app >/dev/null 2>&1 && command -v xdg-terminal-exec >/dev/null 2>&1; then + probe_detached_start "uwsm-xdg-terminal" setsid uwsm-app -- xdg-terminal-exec \ + --app-id=org.omarchy.terminal --title="Citizen Launcher" -e bash "$SELF" "$action" && return 0 + fi + if command -v xdg-terminal-exec >/dev/null 2>&1; then + probe_detached_start "xdg-terminal" xdg-terminal-exec --title="Citizen Launcher" -e bash "$SELF" "$action" && return 0 + fi + if command -v omarchy-launch-floating-terminal-with-presentation >/dev/null 2>&1; then + probe_detached_start "omarchy-terminal" omarchy-launch-floating-terminal-with-presentation bash "$SELF" "$action" && return 0 + fi + notify_error "Das Omarchy-Citizen-Terminal konnte nicht geöffnet werden. Bitte ein Support-Paket erstellen." + return 1 +} + +install_stack_terminal() { + set -Ee + exec > >(tee -a "$LOG_FILE") 2>&1 + setup_failed() { + local rc=$? + trap - ERR + echo + echo "==================================================" + echo " SETUP KONNTE NICHT ABGESCHLOSSEN WERDEN" + echo "==================================================" + echo "Fehlercode: $rc" + echo "Alle Details wurden protokolliert. Im SC-Panel genügt 'Support-Paket erstellen'." + notify "Citizen Launcher" "Setup fehlgeschlagen. Bitte Support-Paket erstellen." + if [[ -t 0 ]]; then read -r -p "Drücke Enter zum Schließen … " _ || true; fi + exit "$rc" + } + trap setup_failed ERR + + echo "==================================================" + echo " Omarchy Citizen – Spielbereit machen" + echo "==================================================" + echo + + local game health reason + game="$(backend_game_status 2>/dev/null || true)" + health="$(printf '%s\n' "$game" | kv_get health)" + if [[ "$health" == "hardware-blocked" ]]; then + reason="$(printf '%s\n' "$game" | kv_get hardware_reason)" + echo "Hardware-Prüfung nicht bestanden:" + echo "${reason:-Keine Star-Citizen-taugliche Vulkan-GPU erkannt.}" + trap - ERR + return 2 + fi + + echo "1/4 Autopilot aktivieren und Gaming-Stack synchronisieren …" + autopilot_enable + echo + echo "2/4 Wine-Runner auf diesem Rechner prüfen …" + wine_doctor + echo + echo "3/4 Wine-Prefix, Komponenten und offiziellen RSI Launcher einrichten …" + backend_game_install + echo + echo "4/4 Wartungsstand abschließend synchronisieren …" + autopilot_maintain || true + + trap - ERR + echo + echo "==================================================" + echo " SOFTWARE-STACK BEREIT" + echo "==================================================" + echo "Der offizielle RSI Launcher ist eingerichtet. Anmeldung und Spieldownload erfolgen dort." + notify "Citizen Launcher" "Software-Stack ist bereit. RSI Launcher kann gestartet werden." +} + +repair_stack_terminal() { + set -Ee + exec > >(tee -a "$LOG_FILE") 2>&1 + repair_failed() { + local rc=$? + trap - ERR + echo + echo "REPARATUR FEHLGESCHLAGEN (Code $rc)" + echo "Bitte im SC-Panel ein Support-Paket erstellen." + notify "Citizen Launcher" "Reparatur fehlgeschlagen. Bitte Support-Paket erstellen." + if [[ -t 0 ]]; then read -r -p "Drücke Enter zum Schließen … " _ || true; fi + exit "$rc" + } + trap repair_failed ERR + + echo "Omarchy Citizen repariert den verwalteten Gaming-Stack …" + autopilot_maintain || true + backend_game_repair + wine_doctor + trap - ERR + echo "Reparatur abgeschlossen." + notify "Citizen Launcher" "Gaming-Stack wurde repariert." +} + +update_stack_terminal() { + set -Ee + exec > >(tee -a "$LOG_FILE") 2>&1 + echo "Synchronisiere Wine, DXVK, RSI Launcher und optionale Support-Werkzeuge …" + autopilot_maintain + echo "Gaming-Stack ist aktuell." +} + +system_update_terminal() { + set -Ee + exec > >(tee -a "$LOG_FILE") 2>&1 + if ! command -v omarchy >/dev/null 2>&1; then + echo "Omarchy-Befehl nicht gefunden." >&2 + return 1 + fi + echo "Starte den offiziellen Omarchy-Systemupdate-Pfad …" + omarchy update -y +} + +primary_action() { + local game health + game="$(backend_game_status 2>/dev/null || true)" + health="$(printf '%s\n' "$game" | kv_get health)" + case "$health" in + hardware-blocked) hardware_info ;; + ready|install-game) backend_game_launch ;; + repair|launcher-repair) launch_terminal_action repair-stack-terminal ;; + *) launch_terminal_action install-stack-terminal ;; + esac +} + +repair_assistant() { + launch_terminal_action repair-stack-terminal +} + +plugin_update_check() { backend_exec check; } +plugin_update_now() { backend_exec update; } +plugin_auto_enable() { backend_exec auto enable; } +plugin_auto_disable() { backend_exec auto disable; } + +open_file() { + local path="$1" + [[ -f "$path" ]] || { notify_error "Datei noch nicht vorhanden: $path"; return 1; } + xdg-open "$path" >/dev/null 2>&1 & +} + +open_debug_log() { open_file "$LOG_FILE"; } +open_updater_log() { open_file "$UPDATER_LOG"; } +open_log() { open_file "$RSI_LOG"; } +open_wiki() { xdg-open "https://wiki.starcitizen-lug.org" >/dev/null 2>&1 & } +open_ngl_page() { xdg-open "https://www.nglproject.com/de#downloads" >/dev/null 2>&1 & } + +open_helper() { + if [[ -x "$MANAGED_LUG" ]]; then + nohup env APPIMAGE_EXTRACT_AND_RUN=1 "$MANAGED_LUG" >>"$LOG_FILE" 2>&1 & + else + open_wiki + fi +} + +sanitize_to() { + local source="$1" target="$2" + [[ -f "$source" ]] || return 0 + if command -v python3 >/dev/null 2>&1 && [[ -f "$SANITIZER" ]]; then + python3 "$SANITIZER" <"$source" >"$target" + else + sed -e "s#${HOME//\#/\\#}#~#g" "$source" >"$target" + fi +} + +capture_sanitized() { + local target="$1"; shift + local tmp="$CACHE_DIR/capture-$$-$RANDOM.txt" + "$@" >"$tmp" 2>&1 || true + sanitize_to "$tmp" "$target" + rm -f "$tmp" +} + +support_build() { + local stamp support_id work downloads archive game_prefix="" + stamp="$(date '+%Y%m%d-%H%M%S')" + support_id="OC-${stamp}-$(printf '%s-%s-%s' "$stamp" "$$" "$RANDOM" | sha256sum | cut -c1-6)" + downloads="$(command -v xdg-user-dir >/dev/null 2>&1 && xdg-user-dir DOWNLOAD 2>/dev/null || true)" + [[ -n "$downloads" && -d "$downloads" ]] || downloads="$HOME/Downloads" + mkdir -p "$downloads" + work="$CACHE_DIR/support-$support_id" + rm -rf "$work" && mkdir -p "$work" + + cat >"$work/README_SUPPORT.txt" <"$work/citizen-status.txt" 2>&1 || true + capture_sanitized "$work/backend-game-status.txt" backend_exec game-status + capture_sanitized "$work/backend-status.txt" backend_exec status + capture_sanitized "$work/wine-doctor.txt" backend_exec doctor + + if command -v omarchy >/dev/null 2>&1; then + capture_sanitized "$work/plugin-validation.txt" omarchy plugin validate "$PLUGIN_DIR" + if omarchy debug --no-sudo --print >"$CACHE_DIR/omarchy-debug-$$.txt" 2>&1; then + sanitize_to "$CACHE_DIR/omarchy-debug-$$.txt" "$work/omarchy-debug.txt" + else + { omarchy version 2>/dev/null || true; omarchy commands --all 2>/dev/null || true; } >"$CACHE_DIR/omarchy-debug-$$.txt" + sanitize_to "$CACHE_DIR/omarchy-debug-$$.txt" "$work/omarchy-debug.txt" + fi + rm -f "$CACHE_DIR/omarchy-debug-$$.txt" + fi + + { + echo "=== kernel ==="; uname -rmo 2>/dev/null || true + echo "=== memory ==="; free -h 2>/dev/null || true + echo "=== filesystems ==="; df -hT / "$HOME" 2>/dev/null || true + echo "=== gpu ==="; lspci -nnk 2>/dev/null | grep -A5 -Ei 'VGA|3D|Display' || true + echo "=== vulkan ==="; command -v vulkaninfo >/dev/null 2>&1 && vulkaninfo --summary 2>&1 || true + echo "=== limits ==="; sysctl vm.max_map_count 2>/dev/null || true; ulimit -Hn 2>/dev/null || true + } >"$CACHE_DIR/system-$$.txt" + sanitize_to "$CACHE_DIR/system-$$.txt" "$work/system.txt" + rm -f "$CACHE_DIR/system-$$.txt" + + for pair in "$LOG_FILE:omarchy-citizen.log" "$UPDATER_LOG:updater.log" "$RSI_LOG:rsi-launcher.log"; do + local src="${pair%%:*}" dst="${pair#*:}" + if [[ -f "$src" ]]; then + tail -n 1800 "$src" >"$CACHE_DIR/log-$$.txt" + sanitize_to "$CACHE_DIR/log-$$.txt" "$work/$dst" + rm -f "$CACHE_DIR/log-$$.txt" + fi + done + + game_prefix="$(backend_game_status 2>/dev/null | kv_get prefix || true)" + if [[ -n "$game_prefix" ]]; then + printf 'prefix=%s\n' "$game_prefix" >"$CACHE_DIR/prefix-$$.txt" + sanitize_to "$CACHE_DIR/prefix-$$.txt" "$work/prefix.txt" + rm -f "$CACHE_DIR/prefix-$$.txt" + fi + + archive="$downloads/Omarchy-Citizen-Support-$support_id.tar.gz" + tar -C "$work" -czf "$archive" . + rm -rf "$work" + notify "Citizen Launcher" "Support-Paket erstellt: $(basename "$archive")" + command -v xdg-open >/dev/null 2>&1 && xdg-open "$downloads" >/dev/null 2>&1 & + printf '%s\n' "$archive" +} + +ngl_info() { + local msg="NGL ist ein optionaler Community-Mod für den RSI Launcher. Omarchy Citizen installiert ihn nur nach deiner ausdrücklichen Dateiauswahl in den verwalteten Wine-Prefix." + if command -v zenity >/dev/null 2>&1; then + zenity --info --width=540 --title="Omarchy Citizen – NGL" --text="$msg" >/dev/null 2>&1 || true + else + printf '%s\n' "$msg" + fi +} + +ngl_install() { + local game prefix selected exe workdir wine + command -v zenity >/dev/null 2>&1 || { notify_error "Für die NGL-Dateiauswahl wird zenity benötigt."; return 1; } + game="$(backend_game_status)" + prefix="$(printf '%s\n' "$game" | kv_get prefix)" + [[ -n "$prefix" && -d "$prefix/drive_c" ]] || { notify_error "Star Citizen muss zuerst eingerichtet werden."; return 1; } + wine="$(readlink -f "$MANAGED_WINE")/bin/wine" + [[ -x "$wine" ]] || { notify_error "Der verwaltete Wine-Runner fehlt."; return 1; } + + selected="$(zenity --file-selection --title="NGL Installer auswählen" --filename="$HOME/Downloads/" \ + --file-filter="NGL Installer | *.zip *.ZIP *.exe *.EXE" --file-filter="Alle Dateien | *" 2>/dev/null)" || return 0 + [[ -f "$selected" ]] || return 1 + exe="$selected" + if [[ "${selected,,}" == *.zip ]]; then + workdir="$CACHE_DIR/ngl-installer" + rm -rf "$workdir" && mkdir -p "$workdir" + unzip -q "$selected" -d "$workdir" || { notify_error "NGL-ZIP konnte nicht entpackt werden."; return 1; } + exe="$(find "$workdir" -type f -iname '*.exe' -print -quit)" + fi + [[ -n "$exe" && -f "$exe" ]] || { notify_error "Kein Windows-Installer gefunden."; return 1; } + zenity --question --width=560 --title="NGL experimentell installieren?" \ + --ok-label="Installer starten" --cancel-label="Abbrechen" \ + --text="NGL ist Community-Code und läuft im selben Wine-Prefix wie der RSI Launcher. Bitte nur Downloads von nglproject.com verwenden." 2>/dev/null || return 0 + nohup env WINEPREFIX="$prefix" WINEDEBUG=-all "$wine" "$exe" >>"$LOG_FILE" 2>&1 & +} + +case "$ACTION" in + status) status ;; + hardware-info) hardware_info ;; + primary) primary_action ;; + setup|install-stack) launch_terminal_action install-stack-terminal ;; + install-stack-terminal) install_stack_terminal ;; + repair|repair-interactive|recover-prefix) repair_assistant ;; + repair-stack-terminal|recover-prefix-terminal) repair_stack_terminal ;; + support|support-build) support_build ;; + debug-log) open_debug_log ;; + log) open_log ;; + updater-log) open_updater_log ;; + plugin-update-check|check-update) plugin_update_check ;; + plugin-update-now) plugin_update_now ;; + plugin-auto-enable) plugin_auto_enable ;; + plugin-auto-disable) plugin_auto_disable ;; + autopilot-enable) autopilot_enable ;; + autopilot-maintain|update-helper) launch_terminal_action update-stack-terminal ;; + update-stack-terminal|update-helper-terminal) update_stack_terminal ;; + wine-doctor) launch_terminal_action wine-doctor-terminal ;; + wine-doctor-terminal) set -e; wine_doctor ;; + system-update) launch_terminal_action system-update-terminal ;; + system-update-terminal) system_update_terminal ;; + launch) backend_game_launch ;; + helper) open_helper ;; + preflight) open_helper ;; + runners|dxvk) launch_terminal_action update-stack-terminal ;; + repair-launch|repair-rsi) launch_terminal_action repair-stack-terminal ;; + dirs) ( xdg-open "$DATA_DIR" >/dev/null 2>&1 & ) ;; + wiki) open_wiki ;; + ngl-page) open_ngl_page ;; + ngl-info) ngl_info ;; + ngl-install|ngl-install-interactive) ngl_install ;; + *) notify_error "Unbekannte Omarchy-Citizen-Aktion: $ACTION"; exit 2 ;; +esac diff --git a/integrations/omarchy/manifest.json b/integrations/omarchy/manifest.json new file mode 100644 index 0000000..c406771 --- /dev/null +++ b/integrations/omarchy/manifest.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "local.omarchy-citizen", + "name": "Citizen Launcher · Omarchy", + "version": "0.9.0", + "author": "Community prototype", + "license": "MIT", + "description": "Optional Omarchy bar integration for the distro-neutral Citizen Launcher.", + "kinds": [ + "bar-widget" + ], + "entryPoints": { + "barWidget": "BarWidget.qml" + }, + "barWidget": { + "displayName": "Omarchy Citizen", + "category": "Gaming", + "allowMultiple": false, + "defaultSection": "right" + } +} diff --git a/integrations/omarchy/support-sanitize.py b/integrations/omarchy/support-sanitize.py new file mode 100644 index 0000000..b5f5047 --- /dev/null +++ b/integrations/omarchy/support-sanitize.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +import os +import re +import socket +import sys + +HOME = os.path.expanduser("~") +USER = os.environ.get("USER", "") +HOST = socket.gethostname() + +patterns = [ + (re.compile(r"(?i)(https?://)[^/@\s]+@"), r"\1@"), + (re.compile(re.escape(HOME)), "~"), + (re.compile(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b"), ""), + (re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), ""), + (re.compile(r"(?i)\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b"), ""), + (re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+"), "Bearer "), + (re.compile(r"(?i)\b(authorization|password|passwd|secret|token|access_token|refresh_token|session|cookie)\s*[:=]\s*[^\s,;]+"), + r"\1="), + (re.compile(r"(?i)([?&](?:token|access_token|refresh_token|session|auth|key|secret)=)[^&#\s]+"), + r"\1"), + (re.compile(r"\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"), ""), +] + +if USER: + patterns.append((re.compile(rf"(?")) +if HOST: + patterns.append((re.compile(rf"(?")) + +for line in sys.stdin: + for pattern, repl in patterns: + line = pattern.sub(repl, line) + sys.stdout.write(line) diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh new file mode 100644 index 0000000..cfcae8c --- /dev/null +++ b/packaging/build-deb.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION="0.9.0"; ARCH="amd64"; WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +PKG="$WORK/citizen-launcher_${VERSION}_${ARCH}" +mkdir -p "$PKG/DEBIAN" "$PKG/usr/bin" "$PKG/usr/share/applications" +install -m755 "$ROOT/backend/bin/citizen-launcher" "$PKG/usr/bin/citizen-launcher" +cat > "$PKG/DEBIAN/control" < "$PKG/usr/share/applications/io.github.citizenlauncher.CitizenLauncher.desktop" </dev/null 2>&1 || true -omarchy plugin disable "$PLUGIN_ID" 2>/dev/null || true - -BACKEND="$HOME/.local/lib/omarchy-citizen/omarchy-citizen-backend" -if [[ -x "$BACKEND" ]]; then - "$BACKEND" auto disable >/dev/null 2>&1 || true - "$BACKEND" uninstall-service >/dev/null 2>&1 || true -fi -rm -f -- "$BACKEND" -rmdir "$HOME/.local/lib/omarchy-citizen" 2>/dev/null || true - -if [[ -d "$DEST" ]]; then - rm -rf -- "$DEST" -fi - -omarchy-shell shell rescanPlugins 2>/dev/null || true -echo "Omarchy Citizen wurde entfernt." -echo "Star Citizen und der LUG Helper wurden NICHT entfernt." +BIN="$HOME/.local/bin/citizen-launcher" +[[ -x "$BIN" ]] && "$BIN" uninstall-service >/dev/null 2>&1 || true +rm -f "$HOME/.local/bin/citizen-launcher" "$HOME/.local/lib/citizen-launcher/citizen-launcher" +rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/applications/io.github.citizenlauncher.CitizenLauncher.desktop" +if command -v omarchy >/dev/null 2>&1; then omarchy plugin disable local.omarchy-citizen >/dev/null 2>&1 || true; fi +echo "Citizen Launcher entfernt. Spiele/PREFIX/Downloads bleiben erhalten."