diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 67e728e..bee7136 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,17 +1,50 @@ -# Architecture +# Citizen Launcher architecture -## Layers +## One source of truth -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. +The Go core under `backend/cmd/citizen-launcher` owns detection, setup, repair, launch, maintenance, support and self-update. Shell/QML integrations are adapters only; they must not duplicate the gaming-stack implementation. -## Why a local embedded web GUI? +```text +Standalone GUI / Omarchy adapter + │ + ▼ + Citizen Launcher Go core + │ + ┌──────────┼───────────┐ + ▼ ▼ ▼ +Preflight Wine RSI metadata + selector │ + + test ▼ + │ │ verified installer + └────┬─────┘ │ + ▼ ▼ + managed Wine prefix + │ │ │ + ▼ ▼ ▼ + Winetricks DXVK PowerShell wrapper + └───────┬────────┘ + ▼ + RSI Launcher / Game +``` -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. +## Activation model -## Update strategy +Mutable downloads are staged first. Executable GitHub release assets require a SHA-256 digest. A Wine candidate is extracted to a staging directory, tested against a throw-away prefix, and only then becomes `vendor/wine/current`. The previous validated runner is retained. -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. +The real game prefix is never used as the Wine compatibility test target. + +## Concurrency model + +- `gui.lock`: one GUI backend process per user. +- in-memory GUI job gate: one long-running GUI action at a time. +- `maintenance.lock`: cross-process serialization of setup/repair/maintenance/launch transition. +- `/proc` exact `WINEPREFIX` inspection: maintenance is deferred while user-facing prefix processes are active. +- launch path rechecks RSI/Star Citizen after acquiring the stack lock, closing the double-click race. + +## GUI + +The UI is embedded in the static binary. It binds an ephemeral port on `127.0.0.1` and uses a cryptographically random per-process route token. No remote web content is needed. A second `citizen-launcher gui` discovers the locked instance and reopens that URL instead of spawning another backend. + +## Distribution boundary + +Citizen Launcher manages Wine/DXVK/RSI in XDG user directories. It does not replace distribution GPU drivers, kernels or package update policy. Debian packaging supplies only the system limits and a narrowly scoped package self-update timer; interactive system preparation uses Polkit rather than passwordless sudo. diff --git a/DISTRO_SUPPORT.md b/DISTRO_SUPPORT.md index c0ded96..b82d403 100644 --- a/DISTRO_SUPPORT.md +++ b/DISTRO_SUPPORT.md @@ -1,13 +1,15 @@ # Distribution support -Citizen Launcher deliberately avoids distro-specific Wine packages. +Citizen Launcher deliberately avoids distro-specific Wine packages so the same tested gaming stack can run across distributions. -| Distribution family | Install method | Autopilot | Notes | +| Distribution family | Primary install | Background maintenance | Status | |---|---|---|---| -| 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 | +| Debian 13 / Ubuntu / Mint | `.deb` | systemd user stack timer + system package self-update timer | primary | +| Fedora | generic binary / `install.sh` (RPM spec included) | systemd user timer | supported core | +| Arch Linux / Omarchy | `install.sh` | systemd user timer | supported core; optional Omarchy adapter | +| openSUSE | generic binary / `install.sh` | systemd user timer | supported core | +| other amd64 desktop Linux | generic binary | systemd timer when available | best effort | -The launcher detects `apt`, `dnf`, `pacman`, `zypper` and `apk` for diagnostics/UI, but does not silently alter the base OS. +Runtime requirements are an x86-64 CPU with AVX, a real Vulkan-capable GPU/driver, a Linux-native executable filesystem for the prefix, sufficient RAM+swap and storage, and basic desktop utilities. Citizen Launcher checks these before mutating the game stack. + +The launcher detects common package managers for diagnostics but does not silently run full distribution upgrades or replace GPU/kernel packages. diff --git a/README.md b/README.md index 6a9542e..3362be6 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,89 @@ -# Citizen Launcher 0.9.2 +# Citizen Launcher 1.0.0 -Citizen Launcher is the distro-neutral successor to Omarchy Citizen. +Citizen Launcher is a distro-neutral Star Citizen setup, launch, repair and maintenance application for Linux. Omarchy support is optional; the core is one static Go binary shared by Debian/Ubuntu, Fedora, Arch/Omarchy and other desktop distributions. -## Goal +## Product goal -**Install Linux → install Citizen Launcher → log into RSI → play.** +**Install Citizen Launcher → click setup → log into RSI → install/play Star Citizen.** -The core is one static Go binary and does not depend on Omarchy. Omarchy is an optional integration under `integrations/omarchy/`. +The launcher owns the fragile user-space gaming stack so users do not have to pick Wine builds, copy DXVK DLLs or rebuild prefixes by hand. Kernel, GPU driver and base-distribution updates remain owned by the Linux distribution. -## Supported targets +## What 1.0 manages -Primary targets: +- hardware/Vulkan/AVX/RAM/storage/filesystem preflight +- Linux `vm.max_map_count` and file-limit preparation +- newest **locally compatible** stable LUG Wine runner with rollback retention +- isolated Wine self-tests before runner activation +- deterministic, SHA-256-pinned Winetricks base setup +- verified portable PowerShell Core + RSI-compatible Wine wrapper (no fragile PowerShell MSI install) +- DXVK download, digest verification, installation and native DLL overrides +- RSI `latest.yml`, SHA-512 verified installer download and launcher repair +- Star Citizen desktop entry and stable launch path +- single-instance GUI, stack-operation locking and duplicate RSI/Star Citizen launch prevention +- automatic gaming-stack maintenance +- automatic verified Debian package self-updates +- privacy-conscious support bundle and rotating logs +- migration from older Omarchy Citizen / user-local installations -- Debian / Ubuntu / Linux Mint -- Fedora -- Arch Linux / Omarchy -- openSUSE (generic user install) +## Install on Debian / Ubuntu / Mint -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 - -```text -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 managed game stack lives in XDG user directories: - -- `~/.config/citizen-launcher` -- `~/.local/share/citizen-launcher` -- `~/.local/state/citizen-launcher` -- `~/.cache/citizen-launcher` - -Existing `omarchy-citizen` data is migrated when possible. - -## Build +Install the release `.deb`: ```bash -./build.sh +sudo apt install ./citizen-launcher_1.0.0_amd64.deb ``` -## Install (any desktop distro) +The package adds the desktop application and enables the system package-update timer. Future Citizen Launcher `.deb` releases can be installed automatically after release-asset digest and package metadata verification. + +## Generic desktop Linux install ```bash ./install.sh ``` -For Omarchy plus bar integration: +or use the release tarball. Fedora/Arch/openSUSE users can use the generic build; an RPM spec is included for packaging work. + +For Omarchy plus the optional bar widget: ```bash ./install-omarchy.sh ``` -## Debian package +## GUI ```bash -./packaging/build-deb.sh +citizen-launcher gui ``` -## Security boundaries +The GUI is embedded in the binary and served only on `127.0.0.1` behind a random per-process route. Chromium-family browsers open it as an app window; otherwise the default browser is used. A file lock guarantees a single GUI backend. Opening Citizen Launcher again reuses the existing instance. -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. +## Data locations +- `~/.config/citizen-launcher` +- `~/.local/share/citizen-launcher` +- `~/.local/state/citizen-launcher` +- `~/.cache/citizen-launcher` +- default Wine prefix: `~/Games/star-citizen` -## 0.9.2 fixes +The uninstaller intentionally preserves the game prefix, game files, configuration and support logs unless the user removes them separately. -- PowerShell is no longer a mandatory prefix component. -- RSI `latest.yml` parser accepts Electron Builder `path:` and nested `files: - url:` formats. -- Repair also refreshes DXVK. -- Responsive dashboard prevents long GPU/distribution names from overflowing. -- GUI shows concise user-facing errors with expandable technical details. +## Build and verification -## Automatic Citizen Launcher updates +```bash +./tests/full-verify.sh +``` -Starting with 0.9.2 the launcher itself is part of Autopilot. +The full gate includes shell syntax, gofmt cleanliness, unit/regression tests, `go vet`, static amd64 build, Go race detector, Omarchy update integration test, Debian package build/metadata/payload verification and generic tarball verification. -### Debian / Ubuntu / Mint +## Security / reliability boundaries -Installing the `.deb` once enables `citizen-launcher-self-update.timer`. Future -Citizen Launcher releases are checked automatically every six hours. A new `.deb` -is only installed after its GitHub SHA-256 asset digest and Debian package metadata -have been verified. +- no passwordless sudo rules are created +- executable release assets fail closed when their expected digest is unavailable +- RSI installer is checked against its published SHA-512 metadata +- Winetricks and the portable RSI PowerShell compatibility assets are version-pinned and checksum-verified +- Wine/DXVK archives are extracted with path/symlink traversal checks +- privileged Debian self-update accepts only the configured release repository, expected package name/version/architecture and verified SHA-256 asset digest +- maintenance never rewrites the active Wine prefix while RSI Launcher/Star Citizen is using it +- incomplete prefixes are preserved instead of blindly deleted -The currently running GUI is never killed during package replacement. If an update -landed while the GUI was open, the app shows **Neue Version installiert** and offers -a controlled restart into the new binary. - -See `packaging/SELF_UPDATE.md` for details. +See `ARCHITECTURE.md`, `DISTRO_SUPPORT.md` and `RELEASE_NOTES.md` for details. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f3e2510..0c21542 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,25 +1,42 @@ -# Citizen Launcher 0.9.2 +# Citizen Launcher 1.0.0 -Maintenance/UX hotfix for the distro-neutral preview. +First productized distro-neutral release. -- Removed mandatory PowerShell Core from base Winetricks setup. -- Robust RSI Electron Builder `latest.yml` parsing (`path`, nested `url`, version fallback). -- DXVK is included in automatic repair. -- Responsive GUI with safer grid sizing and readable diagnostics. -- Added regression tests for RSI metadata formats and the minimal prefix contract. +## Setup and gameplay -## 0.9.2 +- Replaces the old LUG-controlled installation path with one Citizen Launcher core. +- Chooses the newest stable LUG Wine release that actually passes a fresh-prefix self-test on the local CPU/glibc environment; incompatible releases are cached and skipped. +- Preserves a previous working Wine runner and never activates an untested candidate. +- Uses a deterministic SHA-256-pinned Winetricks 20260125 base (`arial`, `tahoma`, `win11` only). +- Avoids the unreliable Winetricks PowerShell MSI path. RSI PowerShell compatibility is provided by verified portable PowerShell Core 7.4.19 and the RSI-compatible Wine wrapper 3.0.5. +- Manages DXVK itself, including DLL verification and Wine native overrides. +- Parses current Electron Builder RSI `latest.yml` layouts and verifies the RSI installer SHA-512 before execution. +- Repairs prefix components, RSI compatibility, DXVK, RSI Launcher and desktop integration without deleting game data. -- fixes `Wine Registry: reg: Angegebener Schlüssel nicht zugreifbar oder erstellbar` - - registry path now uses valid single separators - - file-association tweak is non-fatal because it is not required by Star Citizen -- adds automatic application updates - - Debian package installs a root systemd updater timer - - updater checks GitHub Releases every six hours - - downloads only the expected `citizen-launcher__amd64.deb` - - requires and verifies GitHub's SHA-256 asset digest - - verifies package name/version/architecture before APT installation - - generic user installs self-update from the verified tarball -- GUI shows whether automatic package updates are active -- a running old GUI detects when a newer package was installed and offers a one-click restart -- adds a GitHub Actions release workflow and central `VERSION` file +## Reliability hardening + +- Fixes the historical literal `\\n` Star Citizen desktop-entry regression and automatically repairs old affected launchers. +- Fixes Wine registry path escaping; the file-association tweak is non-fatal. +- Fixes executable permissions when self-sync copies over an existing temporary file. +- Single GUI instance via file lock; a second launch reuses the existing localhost UI. +- One active GUI job at a time plus a cross-process gaming-stack lock. +- Detects a running RSI Launcher or Star Citizen and does not launch duplicates or kill an active game. +- Exact `/proc` `WINEPREFIX` matching avoids false busy/running detections for similarly named prefixes. +- Wine and DXVK release archives use safe extraction with traversal/link checks. +- Downloads are staged and atomically activated; logs rotate and maintenance is deferred while the prefix is busy. + +## System and UX + +- Responsive standalone GUI with concise errors and expandable technical diagnostics. +- Preflight for Vulkan, AVX, RAM+swap, free storage, Linux filesystem, `vm.max_map_count` and open-file limits. +- Debian package installs the recommended system limits and uses Polkit for interactive preparation when needed. +- User-local 0.9.x binaries/desktop entries that shadow a newer Debian package are migrated safely. +- Generic uninstall removes launcher integration but preserves game/config data. + +## Updates and maintenance + +- Debian package installs a systemd timer that checks for a new Citizen Launcher release about every six hours. +- Automatic package update requires the expected GitHub release asset, a SHA-256 digest, matching `citizen-launcher` package name/version and `amd64` architecture before APT is invoked. +- Root updater ignores user-controlled release-repository environment variables and rejects writable/non-root repository configuration. +- Running GUIs are not killed during package replacement; they offer a controlled restart into the installed version. +- CI/release gate runs unit tests, vet, race detector, static build, integration test and package verification. diff --git a/TEST_REPORT.md b/TEST_REPORT.md new file mode 100644 index 0000000..f0ca1d2 --- /dev/null +++ b/TEST_REPORT.md @@ -0,0 +1,55 @@ +# Citizen Launcher 1.0.0 – Verification Report + +Date: 2026-08-31 + +## Release gate + +The 1.0.0 source tree passed the complete automated release gate: + +- shell syntax checks for installer, uninstaller, packaging and integration scripts +- `gofmt` cleanliness +- `go test ./...` +- `go vet ./...` +- static Linux amd64 build +- `go test -race ./...` +- Omarchy updater integration test against disposable local Git repositories +- Debian package build and metadata/payload verification +- generic Linux amd64 tarball build and execution check +- process-level GUI single-instance test: a second GUI invocation reused the first localhost instance +- live GUI `/api/ping` check reporting version 1.0.0 +- systemd service/timer syntax verification with `systemd-analyze verify` +- desktop entry newline regression check (no literal `\\n` in Exec entries) +- AppStream XML parse check +- GitHub Actions CI/release YAML parse check + +## Regression coverage added for 1.0.0 + +The Go test suite explicitly covers: + +- safe archive extraction and traversal/symlink rejection +- exact Wine-prefix process matching +- GUI and stack-operation file locks +- duplicate primary-action handler regression +- Star Citizen/RSI duplicate launch handling +- DXVK DLL/override state +- deterministic Winetricks checksum and minimal base verbs +- legacy user-local binary migration +- broken desktop entry repair from the 0.9.2 literal-newline bug +- copied executable permission preservation +- Debian update package name/version/architecture validation +- release asset selection/version comparison +- Electron-builder RSI `latest.yml` variants +- non-MSI portable PowerShell compatibility path +- Wine registry path escaping +- support-bundle sanitizer behavior +- required SHA-256 release digest fail-closed behavior + +## Final binary / package hashes + +- `backend/bin/citizen-launcher`: `5e55630dc47b89a11185384f09d79fb3813fce51b4d0e30a9660e6526c93a46d` +- `dist/citizen-launcher_1.0.0_amd64.deb`: `976a4cb3efc193f9f05e880ab0456d80e49cb7b5db0a61621d4dc4a788e7c8a1` +- `dist/citizen-launcher-1.0.0-linux-amd64.tar.gz`: `551bca5656382f79fd6e388d14f58555d0db66418a0388ab8a8ba2ac1a058e90` + +## Scope boundary + +The release gate validates the launcher, package, updater, GUI, locks, migration, install/repair logic and local safety properties in the build environment. It cannot perform an RSI account login, download the full Star Citizen game, or execute a real game session with an external GPU from this sandbox. Those remain real-machine acceptance tests rather than simulated release-gate claims. diff --git a/VERSION b/VERSION index 2003b63..3eefcb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.2 +1.0.0 diff --git a/backend/bin/citizen-launcher b/backend/bin/citizen-launcher index b969784..0edb3be 100644 Binary files a/backend/bin/citizen-launcher and b/backend/bin/citizen-launcher differ diff --git a/backend/cmd/citizen-launcher/archive_safe.go b/backend/cmd/citizen-launcher/archive_safe.go new file mode 100644 index 0000000..b53d40f --- /dev/null +++ b/backend/cmd/citizen-launcher/archive_safe.go @@ -0,0 +1,293 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + maxArchiveFileSize = int64(4 << 30) // 4 GiB per file is far above Wine/DXVK needs. + maxArchiveTotal = int64(12 << 30) +) + +type deferredTarLink struct { + name string + linkname string + hard bool +} + +// extractTarArchiveSafe extracts trusted release archives without letting archive +// paths, hardlinks or symlinks escape the staging directory. Compression is +// decoded separately; tar entries themselves are always processed in Go. +func extractTarArchiveSafe(path, dir string) error { + lower := strings.ToLower(path) + f, err := os.Open(path) + if err != nil { + return err + } + + var reader io.Reader = f + var closeFns []func() error + closeFns = append(closeFns, f.Close) + + switch { + case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"): + gz, err := gzip.NewReader(f) + if err != nil { + _ = f.Close() + return err + } + reader = gz + closeFns = append([]func() error{gz.Close}, closeFns...) + case strings.HasSuffix(lower, ".tar.xz"): + _ = f.Close() + if !commandExists("xz") { + return errors.New("xz wird zum Entpacken dieses Wine-Archivs benötigt") + } + cmd := exec.Command("xz", "-dc", "--", path) + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + var stderr strings.Builder + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + return err + } + extractErr := extractTarReaderSafe(stdout, dir) + if extractErr != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + waitErr := cmd.Wait() + if extractErr != nil { + return extractErr + } + if waitErr != nil { + return fmt.Errorf("xz konnte Archiv nicht dekomprimieren: %s", compactDiagnostic(stderr.String())) + } + return nil + case strings.HasSuffix(lower, ".tar.zst"), strings.HasSuffix(lower, ".tar.zstd"): + _ = f.Close() + if !commandExists("zstd") { + return errors.New("zstd wird zum Entpacken dieses Wine-Archivs benötigt") + } + cmd := exec.Command("zstd", "-q", "-dc", "--", path) + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + var stderr strings.Builder + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + return err + } + extractErr := extractTarReaderSafe(stdout, dir) + if extractErr != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + waitErr := cmd.Wait() + if extractErr != nil { + return extractErr + } + if waitErr != nil { + return fmt.Errorf("zstd konnte Archiv nicht dekomprimieren: %s", compactDiagnostic(stderr.String())) + } + return nil + case strings.HasSuffix(lower, ".tar"): + // plain tar + default: + for _, closeFn := range closeFns { + _ = closeFn() + } + return fmt.Errorf("nicht unterstütztes Archivformat: %s", filepath.Base(path)) + } + + err = extractTarReaderSafe(reader, dir) + for _, closeFn := range closeFns { + if closeErr := closeFn(); err == nil && closeErr != nil { + err = closeErr + } + } + return err +} + +func extractTarReaderSafe(r io.Reader, dir string) error { + root, err := filepath.Abs(dir) + if err != nil { + return err + } + if err := os.MkdirAll(root, 0o755); err != nil { + return err + } + + tr := tar.NewReader(r) + var links []deferredTarLink + var total int64 + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + name, target, err := safeArchiveTarget(root, h.Name) + if err != nil { + return err + } + if name == "." { + continue + } + + switch h.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg, tar.TypeRegA: + if h.Size < 0 || h.Size > maxArchiveFileSize { + return fmt.Errorf("Archivdatei zu groß: %q", h.Name) + } + total += h.Size + if total > maxArchiveTotal { + return errors.New("Archiv überschreitet das sichere Größenlimit") + } + if err := ensureNoSymlinkParents(root, target); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + mode := os.FileMode(h.Mode) & 0o755 + if mode == 0 { + mode = 0o644 + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + _, copyErr := io.CopyN(out, tr, h.Size) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + case tar.TypeSymlink: + links = append(links, deferredTarLink{name: name, linkname: h.Linkname}) + case tar.TypeLink: + links = append(links, deferredTarLink{name: name, linkname: h.Linkname, hard: true}) + default: + return fmt.Errorf("nicht unterstützter/unsicherer Tar-Eintrag %q (Typ %d)", h.Name, h.Typeflag) + } + } + + // Hardlinks first, then symlinks. Deferring links prevents a later regular + // archive member from writing through a just-created symlink parent. + for _, link := range links { + if !link.hard { + continue + } + _, dst, err := safeArchiveTarget(root, link.name) + if err != nil { + return err + } + _, src, err := safeArchiveTarget(root, link.linkname) + if err != nil { + return fmt.Errorf("unsicherer Hardlink %q -> %q: %w", link.name, link.linkname, err) + } + if err := ensureNoSymlinkParents(root, dst); err != nil { + return err + } + if fi, err := os.Stat(src); err != nil || !fi.Mode().IsRegular() { + return fmt.Errorf("Hardlink-Ziel ist keine reguläre Datei: %q", link.linkname) + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + _ = os.Remove(dst) + if err := os.Link(src, dst); err != nil { + return err + } + } + for _, link := range links { + if link.hard { + continue + } + _, dst, err := safeArchiveTarget(root, link.name) + if err != nil { + return err + } + if filepath.IsAbs(link.linkname) { + return fmt.Errorf("absoluter Symlink im Archiv: %q -> %q", link.name, link.linkname) + } + resolved := filepath.Clean(filepath.Join(filepath.Dir(dst), filepath.FromSlash(link.linkname))) + if !pathInside(root, resolved) { + return fmt.Errorf("Symlink verlässt Zielverzeichnis: %q -> %q", link.name, link.linkname) + } + if err := ensureNoSymlinkParents(root, dst); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + _ = os.Remove(dst) + if err := os.Symlink(filepath.FromSlash(link.linkname), dst); err != nil { + return err + } + } + return nil +} + +func safeArchiveTarget(root, archiveName string) (string, string, error) { + name := filepath.Clean(filepath.FromSlash(strings.TrimSpace(archiveName))) + if name == "" || name == "." { + return ".", root, nil + } + if filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, ".."+string(os.PathSeparator)) { + return "", "", fmt.Errorf("unsicherer Archivpfad: %q", archiveName) + } + target := filepath.Join(root, name) + if !pathInside(root, target) { + return "", "", fmt.Errorf("Archivpfad verlässt Zielverzeichnis: %q", archiveName) + } + return name, target, nil +} + +func pathInside(root, target string) bool { + rel, err := filepath.Rel(root, target) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +} + +func ensureNoSymlinkParents(root, target string) error { + rel, err := filepath.Rel(root, filepath.Dir(target)) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return errors.New("Zielpfad liegt außerhalb des Entpackverzeichnisses") + } + cur := root + if rel == "." { + return nil + } + for _, part := range strings.Split(rel, string(os.PathSeparator)) { + cur = filepath.Join(cur, part) + fi, err := os.Lstat(cur) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + if fi.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("Symlink als Archiv-Zwischenpfad wird abgelehnt: %s", cur) + } + } + return nil +} diff --git a/backend/cmd/citizen-launcher/archive_safe_test.go b/backend/cmd/citizen-launcher/archive_safe_test.go new file mode 100644 index 0000000..6d8832d --- /dev/null +++ b/backend/cmd/citizen-launcher/archive_safe_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "os" + "path/filepath" + "testing" +) + +type tarTestEntry struct { + name string + link string + typ byte + data []byte + mode int64 +} + +func makeTarGz(t *testing.T, entries []tarTestEntry) string { + t.Helper() + path := filepath.Join(t.TempDir(), "fixture.tar.gz") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + for _, e := range entries { + mode := e.mode + if mode == 0 { + mode = 0o644 + } + h := &tar.Header{Name: e.name, Linkname: e.link, Typeflag: e.typ, Mode: mode, Size: int64(len(e.data))} + if e.typ == tar.TypeSymlink || e.typ == tar.TypeLink || e.typ == tar.TypeDir { + h.Size = 0 + } + if err := tw.WriteHeader(h); err != nil { + t.Fatal(err) + } + if len(e.data) != 0 { + if _, err := tw.Write(e.data); err != nil { + t.Fatal(err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return path +} + +func TestExtractTarArchiveSafeRegularAndSymlink(t *testing.T) { + archive := makeTarGz(t, []tarTestEntry{ + {name: "runner/bin/wine", typ: tar.TypeReg, data: []byte("wine"), mode: 0o755}, + {name: "runner/bin/wine64", typ: tar.TypeSymlink, link: "wine"}, + }) + dst := t.TempDir() + if err := extractTarArchiveSafe(archive, dst); err != nil { + t.Fatal(err) + } + if b, err := os.ReadFile(filepath.Join(dst, "runner", "bin", "wine")); err != nil || string(b) != "wine" { + t.Fatalf("regular file missing/corrupt: %q %v", b, err) + } + link, err := os.Readlink(filepath.Join(dst, "runner", "bin", "wine64")) + if err != nil || link != "wine" { + t.Fatalf("safe symlink missing: %q %v", link, err) + } +} + +func TestExtractTarArchiveSafeRejectsTraversal(t *testing.T) { + for _, entries := range [][]tarTestEntry{ + {{name: "../escape", typ: tar.TypeReg, data: []byte("x")}}, + {{name: "runner/link", typ: tar.TypeSymlink, link: "../../escape"}}, + {{name: "/absolute", typ: tar.TypeReg, data: []byte("x")}}, + } { + if err := extractTarArchiveSafe(makeTarGz(t, entries), t.TempDir()); err == nil { + t.Fatalf("unsafe tar accepted: %#v", entries) + } + } +} diff --git a/backend/cmd/citizen-launcher/autopilot.go b/backend/cmd/citizen-launcher/autopilot.go index e9f9117..68dd777 100644 --- a/backend/cmd/citizen-launcher/autopilot.go +++ b/backend/cmd/citizen-launcher/autopilot.go @@ -34,10 +34,11 @@ type githubAsset struct { } type componentMeta struct { - Version string `json:"version"` - Asset string `json:"asset,omitempty"` - URL string `json:"url,omitempty"` - Updated string `json:"updated"` + Version string `json:"version"` + Asset string `json:"asset,omitempty"` + URL string `json:"url,omitempty"` + Updated string `json:"updated"` + Fingerprint string `json:"fingerprint,omitempty"` } type wineRejection struct { @@ -47,6 +48,25 @@ type wineRejection struct { Updated string `json:"updated"` } +func (a *App) syncMaintenanceTimer() error { + cfg := a.loadConfig() + want := cfg.AutoMaintain || cfg.AutoApply + if !detectPlatform().SystemdUser { + if want { + return a.selfSync() + } + return nil + } + if want { + if err := a.installService(); err != nil { + return err + } + return run("", "systemctl", "--user", "enable", "--now", "citizen-launcher-maintenance.timer") + } + _ = run("", "systemctl", "--user", "disable", "--now", "citizen-launcher-maintenance.timer") + return nil +} + func (a *App) enableAutopilot() error { cfg := a.loadConfig() cfg.AutoMaintain = true @@ -61,21 +81,8 @@ func (a *App) enableAutopilot() error { } 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") + if err := a.syncMaintenanceTimer(); err != nil { + return err } a.logf("autopilot enabled auto_integration=%v", cfg.AutoApply) fmt.Println("enabled") @@ -87,6 +94,9 @@ func (a *App) disableAutopilot() error { cfg.AutoMaintain = false cfg.LastResult = "autopilot-disabled" a.saveConfig(cfg) + if err := a.syncMaintenanceTimer(); err != nil { + return err + } a.logf("autopilot disabled") fmt.Println("disabled") return nil @@ -99,13 +109,32 @@ func (a *App) maintainGamingStack() error { } defer unlock() + gc := a.loadGameConfig() + if prefixInitialized(gc.Prefix) && prefixBusy(gc.Prefix) { + a.logf("maintenance deferred: Wine prefix is active") + return nil + } + a.logf("maintenance begin") var problems []string // LUG is no longer synchronized during normal maintenance. It is only a // lazy portable-toolbox fallback when distro tools are missing. + wineReady := true if err := a.syncWineRunner(); err != nil { problems = append(problems, "Wine runner: "+err.Error()) + wineReady = false + } + // Keep the RSI PowerShell compatibility layer healthy without invoking + // Winetricks/MSI during background maintenance. The prefix-busy guard above + // ensures that no live RSI/game process is modified underneath the user. + if wineReady && prefixInitialized(gc.Prefix) && a.powerShellState(gc.Prefix) != "ready" { + env, _, envErr := a.runnerEnv(gc.Prefix) + if envErr != nil { + problems = append(problems, "RSI compatibility: "+envErr.Error()) + } else if psErr := a.ensurePowerShell(gc, env); psErr != nil { + problems = append(problems, "RSI compatibility: "+psErr.Error()) + } } if err := a.syncDXVK(); err != nil { problems = append(problems, "DXVK: "+err.Error()) @@ -122,6 +151,7 @@ func (a *App) maintainGamingStack() error { c.LastMaintenance = time.Now().Format(time.RFC3339) c.MaintenanceResult = result }) + a.cleanupManagedState() a.logf("maintenance end result=%s", result) if len(problems) > 0 { @@ -159,7 +189,7 @@ func (a *App) syncLUGHelper() error { if err := download(asset.BrowserDownloadURL, tmp); err != nil { return err } - if err := verifyReleaseDigest(tmp, asset.Digest); err != nil { + if err := requireReleaseDigest(tmp, asset.Digest); err != nil { _ = os.Remove(tmp) return err } @@ -204,17 +234,7 @@ func (a *App) syncWineRunner() error { 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")) - }) + asset, assetErr := selectWineAsset(release.Assets) if assetErr != nil { continue } @@ -226,12 +246,28 @@ func (a *App) syncWineRunner() error { continue } - // Already-active compatible version: no download/test needed. - if readMetaVersion(metaPath) == release.TagName { + // Re-test an already-active runner when the CPU/libc fingerprint changed. + // This catches an OS upgrade that invalidates a previously working runner. + if meta := readComponentMeta(metaPath); meta.Version == release.TagName { if p, _ := filepath.EvalSymlinks(current); p != "" { if _, err := os.Stat(filepath.Join(p, "bin", "wine")); err == nil { - a.activateRunnerForPrefix(p) - return nil + if meta.Fingerprint == fingerprint { + a.activateRunnerForPrefix(p) + return nil + } + diag, testErr := a.selfTestRunnerDetailed(p) + if testErr == nil { + meta.Fingerprint = fingerprint + meta.Updated = time.Now().Format(time.RFC3339) + _ = writeMeta(metaPath, meta) + a.logf("active Wine runner revalidated: %s diagnostic=%s", release.TagName, compactDiagnostic(diag)) + a.activateRunnerForPrefix(p) + return nil + } + reason := testErr.Error() + rejections[release.TagName] = wineRejection{Version: release.TagName, Reason: reason, Fingerprint: fingerprint, Updated: time.Now().Format(time.RFC3339)} + _ = writeWineRejections(rejectPath, rejections) + failures = append(failures, release.TagName+" no longer compatible: "+reason) } } } @@ -241,7 +277,7 @@ func (a *App) syncWineRunner() error { failures = append(failures, release.TagName+" download: "+err.Error()) continue } - if err := verifyReleaseDigest(archive, asset.Digest); err != nil { + if err := requireReleaseDigest(archive, asset.Digest); err != nil { _ = os.Remove(archive) failures = append(failures, release.TagName+" checksum: "+err.Error()) continue @@ -252,11 +288,9 @@ func (a *App) syncWineRunner() error { return err } - extract := exec.Command("tar", "-xf", archive, "-C", stage) - out, extractErr := extract.CombinedOutput() - if extractErr != nil { + if extractErr := extractTarArchiveSafe(archive, stage); extractErr != nil { os.RemoveAll(stage) - failures = append(failures, release.TagName+" extract: "+formatCommandFailure(extractErr, out)) + failures = append(failures, release.TagName+" extract: "+extractErr.Error()) continue } @@ -294,6 +328,16 @@ func (a *App) syncWineRunner() error { return err } + // Preserve one known-previous runner for diagnostics/manual rollback. + if oldCurrent, err := filepath.EvalSymlinks(current); err == nil && oldCurrent != "" && oldCurrent != target { + previous := filepath.Join(base, "previous") + tmpPrevious := previous + ".new" + _ = os.Remove(tmpPrevious) + if os.Symlink(oldCurrent, tmpPrevious) == nil { + _ = os.Rename(tmpPrevious, previous) + } + } + tmpLink := current + ".new" _ = os.Remove(tmpLink) if err := os.Symlink(target, tmpLink); err != nil { @@ -307,7 +351,7 @@ func (a *App) syncWineRunner() error { if err := writeMeta(metaPath, componentMeta{ Version: release.TagName, Asset: asset.Name, URL: asset.BrowserDownloadURL, - Updated: time.Now().Format(time.RFC3339), + Updated: time.Now().Format(time.RFC3339), Fingerprint: fingerprint, }); err != nil { os.RemoveAll(stage) return err @@ -322,6 +366,19 @@ func (a *App) syncWineRunner() error { return nil } + // A previously validated runner may have fallen out of the recent-release + // window. Never strand a working installation just because newer candidates + // are incompatible with this CPU/libc combination. + if p, err := filepath.EvalSymlinks(current); err == nil && p != "" { + if diag, testErr := a.selfTestRunnerDetailed(p); testErr == nil { + meta := readComponentMeta(metaPath) + meta.Fingerprint = fingerprint + meta.Updated = time.Now().Format(time.RFC3339) + _ = writeMeta(metaPath, meta) + a.logf("no newer compatible Wine runner; keeping %s diagnostic=%s", meta.Version, compactDiagnostic(diag)) + return nil + } + } if len(failures) == 0 { return errors.New("no stable LUG Wine asset found in recent releases") } @@ -355,7 +412,8 @@ func (a *App) selfTestRunnerDetailed(runner string) (string, error) { } defer os.RemoveAll(tmp) - env := append(os.Environ(), + env := environmentWithout(os.Environ(), "SDL_VIDEODRIVER", "WINE", "WINESERVER", "WINEPREFIX", "WINEARCH", "WINEDEBUG", "WINEDLLOVERRIDES") + env = append(env, "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "WINE="+wine, "WINEPREFIX="+tmp, @@ -440,13 +498,19 @@ func (a *App) selfTestRunnerDetailed(runner string) (string, error) { func formatCommandFailure(err error, out []byte) string { text := strings.TrimSpace(string(out)) + if len(text) > 1200 { + // Keep status output compact but preserve enough detail in updater.log. + text = text[len(text)-1200:] + } + if err == nil { + if text == "" { + return "command produced no expected result" + } + return text + } if text == "" { return err.Error() } - // Keep status output compact but preserve enough detail in updater.log. - if len(text) > 1200 { - text = text[len(text)-1200:] - } return err.Error() + ": " + text } @@ -459,6 +523,11 @@ func compactDiagnostic(s string) string { } func (a *App) wineDoctor() error { + lock, err := a.stackOperationLock(2 * time.Second) + if err != nil { + return err + } + defer releaseFileLock(lock) current := filepath.Join(a.vendorDir, "wine", "current") root, err := filepath.EvalSymlinks(current) if err != nil { @@ -504,14 +573,73 @@ func (a *App) activateRunnerForPrefix(runner string) { } } +var dxvkRequiredDLLs = []string{"d3d8.dll", "d3d9.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll"} +var dxvkOverrideNames = []string{"d3d8", "d3d9", "d3d10core", "d3d11", "dxgi"} + +func (a *App) dxvkMarkerPath() string { return filepath.Join(a.vendorDir, "dxvk", "overrides.txt") } + +func (a *App) dxvkDLLsReady(prefix string) bool { + if !prefixInitialized(prefix) { + return false + } + system32 := filepath.Join(prefix, "drive_c", "windows", "system32") + for _, name := range dxvkRequiredDLLs { + fi, err := os.Stat(filepath.Join(system32, name)) + if err != nil || fi.Size() < 1024 { + return false + } + } + return true +} + +func (a *App) dxvkState(prefix, version string) string { + if !prefixInitialized(prefix) || version == "" { + return "missing" + } + if !a.dxvkDLLsReady(prefix) { + return "repair" + } + marker := readText(a.dxvkMarkerPath()) + want := version + "\n" + prefix + if marker != want { + return "repair" + } + return "ready" +} + +func (a *App) ensureDXVKOverrides(prefix, version string) error { + if prefixBusy(prefix) { + return errors.New("DXVK kann nicht geändert werden, solange der Wine-Prefix verwendet wird") + } + env, runner, err := a.runnerEnv(prefix) + if err != nil { + return err + } + wine := filepath.Join(runner, "bin", "wine") + const key = `HKEY_CURRENT_USER\Software\Wine\DllOverrides` + for _, dll := range dxvkOverrideNames { + cmd := exec.Command(wine, "reg", "add", key, "/v", dll, "/t", "REG_SZ", "/d", "native", "/f") + cmd.Env = env + out, runErr := cmd.CombinedOutput() + if runErr != nil { + return fmt.Errorf("DXVK DLL-Override %s: %s", dll, formatCommandFailure(runErr, out)) + } + } + wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w") + wait.Env = env + if out, runErr := wait.CombinedOutput(); runErr != nil { + return fmt.Errorf("DXVK Registry speichern: %s", formatCommandFailure(runErr, out)) + } + return atomicWriteFile(a.dxvkMarkerPath(), []byte(version+"\n"+prefix+"\n"), 0o600) +} + func (a *App) syncDXVK() error { prefix := a.configuredPrefix() if prefix == "" || !prefixInitialized(prefix) { return nil } if prefixBusy(prefix) { - a.logf("DXVK update deferred: Star Citizen Wine prefix is active") - return nil + return errors.New("DXVK-Aktualisierung pausiert: Wine-Prefix ist aktiv") } release, err := githubLatest(dxvkRepo) @@ -528,7 +656,11 @@ func (a *App) syncDXVK() error { base := filepath.Join(a.vendorDir, "dxvk") metaPath := filepath.Join(base, "meta.json") - if readMetaVersion(metaPath) == release.TagName { + currentVersion := readMetaVersion(metaPath) + if currentVersion == release.TagName && a.dxvkDLLsReady(prefix) { + if err := a.ensureDXVKOverrides(prefix, release.TagName); err != nil { + return err + } return nil } if err := os.MkdirAll(base, 0o755); err != nil { @@ -541,7 +673,7 @@ func (a *App) syncDXVK() error { if err := download(asset.BrowserDownloadURL, archive); err != nil { return err } - if err := verifyReleaseDigest(archive, asset.Digest); err != nil { + if err := requireReleaseDigest(archive, asset.Digest); err != nil { _ = os.Remove(archive) return err } @@ -550,8 +682,8 @@ func (a *App) syncDXVK() error { 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))) + if err := extractTarArchiveSafe(archive, stage); err != nil { + return fmt.Errorf("DXVK entpacken: %w", err) } root, err := findRootContaining(stage, filepath.Join("x64", "dxgi.dll")) if err != nil { @@ -571,13 +703,19 @@ func (a *App) syncDXVK() error { return err } } + if !a.dxvkDLLsReady(prefix) { + return errors.New("DXVK-Installation unvollständig: benötigte x64-DLLs fehlen") + } + if err := a.ensureDXVKOverrides(prefix, release.TagName); err != nil { + return err + } if err := writeMeta(metaPath, componentMeta{ Version: release.TagName, Asset: asset.Name, URL: asset.BrowserDownloadURL, Updated: time.Now().Format(time.RFC3339), }); err != nil { return err } - a.logf("DXVK updated to %s", release.TagName) + a.logf("DXVK updated to %s and native DLL overrides activated", release.TagName) _ = os.Remove(archive) return nil } @@ -628,7 +766,13 @@ func prefixBusy(prefix string) bool { if err != nil { return false } - needle := []byte("WINEPREFIX=" + prefix) + // Wine keeps a few infrastructure processes around briefly even when no + // user-facing program is active. Those are safe to terminate/restart while + // holding the stack lock and must not permanently block maintenance. + infrastructure := []string{ + "wineserver", "services.exe", "winedevice.exe", "explorer.exe", + "plugplay.exe", "rpcss.exe", "svchost.exe", "conhost.exe", + } for _, e := range proc { if !e.IsDir() { continue @@ -638,7 +782,19 @@ func prefixBusy(prefix string) bool { continue } data, err := os.ReadFile(filepath.Join("/proc", name, "environ")) - if err == nil && strings.Contains(string(data), string(needle)) { + if err != nil || !processHasEnvValue(data, "WINEPREFIX", prefix) { + continue + } + cmdline, _ := os.ReadFile(filepath.Join("/proc", name, "cmdline")) + cmd := strings.ToLower(strings.ReplaceAll(string(cmdline), "\x00", " ")) + ignored := false + for _, infra := range infrastructure { + if strings.Contains(cmd, infra) { + ignored = true + break + } + } + if !ignored { return true } } @@ -743,6 +899,31 @@ func githubLatest(repo string) (githubRelease, error) { return rel, nil } +func selectWineAsset(assets []githubAsset) (githubAsset, error) { + valid := func(n string) bool { + n = strings.ToLower(n) + if strings.Contains(n, "staging") || strings.Contains(n, "checksum") || + strings.Contains(n, "sha") || strings.Contains(n, "experimental") || + strings.Contains(n, "wayland") || !strings.Contains(n, "lug-wine-tkg-git") { + return false + } + return strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || + strings.HasSuffix(n, ".tar.xz") || strings.HasSuffix(n, ".tar.zst") + } + // Prefer gzip: it is decoded entirely in-process and therefore works on all + // supported distributions without another decompressor. XZ/Zstd remain + // compatible fallbacks for future upstream packaging changes. + for _, suffix := range []string{".tar.gz", ".tgz", ".tar.xz", ".tar.zst"} { + asset, err := selectAsset(assets, func(n string) bool { + return valid(n) && strings.HasSuffix(strings.ToLower(n), suffix) + }) + if err == nil { + return asset, nil + } + } + return githubAsset{}, errors.New("no stable LUG Wine archive found") +} + func selectAsset(assets []githubAsset, match func(string) bool) (githubAsset, error) { var candidates []githubAsset for _, a := range assets { @@ -855,38 +1036,98 @@ func copyTree(src, dst string) error { }) } -func readMetaVersion(path string) string { +func readComponentMeta(path string) componentMeta { data, err := os.ReadFile(path) if err != nil { - return "" + return componentMeta{} } var meta componentMeta if json.Unmarshal(data, &meta) != nil { - return "" + return componentMeta{} } - return meta.Version + return meta } +func readMetaVersion(path string) string { return readComponentMeta(path).Version } + func writeMeta(path string, meta componentMeta) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } data, _ := json.MarshalIndent(meta, "", " ") data = append(data, '\n') - tmp := path + ".new" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, path) + return atomicWriteFile(path, data, 0o644) } 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) + _ = atomicWriteFile(a.configPath, data, 0o600) +} + +func (a *App) cleanupManagedState() { + // Keep the active and previous Wine runners; old extracted runners can be + // large and are safe to re-download if ever needed again. + wineBase := filepath.Join(a.vendorDir, "wine") + keep := map[string]bool{} + for _, link := range []string{"current", "previous"} { + if p, err := filepath.EvalSymlinks(filepath.Join(wineBase, link)); err == nil && p != "" { + keep[filepath.Clean(p)] = true + } + } + if entries, err := os.ReadDir(wineBase); err == nil { + for _, e := range entries { + if !e.IsDir() || strings.HasSuffix(e.Name(), ".new") { + continue + } + p := filepath.Join(wineBase, e.Name()) + if !keep[filepath.Clean(p)] { + _ = os.RemoveAll(p) + } + } + } + + pruneNamedDirectories(filepath.Join(a.vendorDir, "winetricks"), 2, "") + pruneNamedDirectories(a.stateDir, 3, "dxvk-backup-") + pruneOldCache(a.cacheDir, 30*24*time.Hour) +} + +func pruneNamedDirectories(base string, keep int, prefix string) { + entries, err := os.ReadDir(base) + if err != nil { + return + } + type item struct { + path string + mod time.Time + } + var items []item + for _, e := range entries { + if !e.IsDir() || (prefix != "" && !strings.HasPrefix(e.Name(), prefix)) { + continue + } + if info, err := e.Info(); err == nil { + items = append(items, item{filepath.Join(base, e.Name()), info.ModTime()}) + } + } + sort.Slice(items, func(i, j int) bool { return items[i].mod.After(items[j].mod) }) + for i := keep; i < len(items); i++ { + _ = os.RemoveAll(items[i].path) + } +} + +func pruneOldCache(base string, age time.Duration) { + entries, err := os.ReadDir(base) + if err != nil { + return + } + cutoff := time.Now().Add(-age) + for _, e := range entries { + info, err := e.Info() + if err == nil && info.ModTime().Before(cutoff) { + _ = os.RemoveAll(filepath.Join(base, e.Name())) + } } } @@ -927,3 +1168,13 @@ func verifyReleaseDigest(path, digest string) error { } return nil } + +// Executable gaming-stack components fail closed when GitHub does not provide +// a SHA-256 digest. An upstream metadata outage is recoverable; activating an +// unverifiable Wine/DXVK/toolbox download is not. +func requireReleaseDigest(path, digest string) error { + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(digest)), "sha256:") { + return fmt.Errorf("GitHub asset %s has no SHA-256 digest; refusing unverified component", filepath.Base(path)) + } + return verifyReleaseDigest(path, digest) +} diff --git a/backend/cmd/citizen-launcher/gui.go b/backend/cmd/citizen-launcher/gui.go index 0945ed8..5d2b7ef 100644 --- a/backend/cmd/citizen-launcher/gui.go +++ b/backend/cmd/citizen-launcher/gui.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strings" "sync" + "syscall" "time" ) @@ -29,6 +30,7 @@ type GUIStatus struct { Platform PlatformStatus `json:"platform"` Game GameStatus `json:"game"` Launcher Status `json:"launcher"` + ActiveJob *GUIJob `json:"active_job,omitempty"` } type GUIJob struct { @@ -42,8 +44,107 @@ type GUIJob struct { } type jobStore struct { - mu sync.Mutex - jobs map[string]*GUIJob + mu sync.Mutex + jobs map[string]*GUIJob + activeID string +} + +func (s *jobStore) active() *GUIJob { + s.mu.Lock() + defer s.mu.Unlock() + if s.activeID == "" { + return nil + } + j := s.jobs[s.activeID] + if j == nil || j.State != "running" { + s.activeID = "" + return nil + } + copy := *j + return © +} + +func (s *jobStore) start(action string) (*GUIJob, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.activeID != "" { + if j := s.jobs[s.activeID]; j != nil && j.State == "running" { + return nil, fmt.Errorf("%s läuft bereits", friendlyActionName(j.Action)) + } + s.activeID = "" + } + idBytes := make([]byte, 8) + if _, err := rand.Read(idBytes); err != nil { + return nil, fmt.Errorf("sichere Job-ID konnte nicht erzeugt werden: %w", err) + } + j := &GUIJob{ID: hex.EncodeToString(idBytes), Action: action, State: "running", Started: time.Now().Format(time.RFC3339)} + s.jobs[j.ID] = j + s.activeID = j.ID + return j, nil +} + +func (s *jobStore) finish(id string, err error, msg string) { + s.mu.Lock() + defer s.mu.Unlock() + j := s.jobs[id] + if j == nil { + return + } + j.Finished = time.Now().Format(time.RFC3339) + if err != nil { + j.State = "error" + j.Message = friendlyActionError(j.Action, err) + j.Error = err.Error() + } else { + j.State = "done" + j.Message = msg + } + if s.activeID == id { + s.activeID = "" + } + // Bound memory for a GUI process that may stay open for days. + if len(s.jobs) > 60 { + for key, old := range s.jobs { + if old.State != "running" && key != id { + delete(s.jobs, key) + } + if len(s.jobs) <= 30 { + break + } + } + } +} + +func (s *jobStore) get(id string) *GUIJob { + s.mu.Lock() + defer s.mu.Unlock() + j := s.jobs[id] + if j == nil { + return nil + } + copy := *j + return © +} + +func friendlyActionName(action string) string { + switch action { + case "setup": + return "Einrichtung" + case "repair": + return "Reparatur" + case "maintain": + return "Gaming-Stack-Wartung" + case "doctor": + return "Wine-Selbsttest" + case "support": + return "Support-Paket" + case "prepare-system": + return "Systemvorbereitung" + case "choose-prefix": + return "Ordnerauswahl" + default: + return "Eine Aktion" + } } func friendlyActionError(action string, err error) string { @@ -52,14 +153,20 @@ func friendlyActionError(action string, err error) string { } s := err.Error() switch { + case errors.Is(err, ErrLauncherAlreadyRunning): + return "Der RSI Launcher läuft bereits." + case strings.Contains(s, "PowerShell-Kompatibilität") || strings.Contains(s, "PowerShell Selbsttest"): + return "Die für den RSI Launcher benötigte Windows-Kompatibilität konnte nicht vollständig eingerichtet werden. Citizen Launcher hat keine Spieldaten verändert." case strings.Contains(s, "Windows-Komponente"): - return "Eine Windows-Komponente konnte nicht eingerichtet werden. Der technische Fehler ist unten verfügbar." + return "Eine Windows-Komponente konnte nicht eingerichtet werden. Die technischen Details sind unten verfügbar." case strings.Contains(s, "RSI latest.yml") || strings.Contains(s, "RSI Installer"): return "Die aktuelle RSI-Launcher-Version konnte nicht zuverlässig ermittelt werden." case strings.Contains(s, "RSI Launcher Installation"): return "Der RSI Launcher konnte nicht installiert oder aktualisiert werden." case strings.Contains(strings.ToLower(s), "vulkan"): return "Vulkan ist auf diesem System nicht spielbereit." + case strings.Contains(strings.ToLower(s), "nofile") || strings.Contains(strings.ToLower(s), "max_map_count"): + return "Eine notwendige Linux-Systemeinstellung konnte nicht vorbereitet werden." case strings.Contains(strings.ToLower(s), "wine"): return "Der Wine-Stack konnte den Selbsttest oder die Reparatur nicht abschließen." default: @@ -72,17 +179,52 @@ func friendlyActionError(action string, err error) string { func (a *App) runGUI(args []string) error { noOpen := hasArg(args, "--no-open") + guiLock, acquired, err := a.acquireGUILock() + if err != nil { + return err + } + if !acquired { + if existing, ok := a.existingGUIURL(); ok { + fmt.Println(existing) + if noOpen { + return nil + } + return openGUIURL(existing, a.cacheDir) + } + return errors.New("Citizen Launcher läuft bereits, reagiert aber noch nicht. Bitte kurz warten und erneut öffnen.") + } + defer releaseFileLock(guiLock) + defer os.Remove(a.guiInfoPath()) + 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 + if _, err := rand.Read(tokenBytes); err != nil { + _ = listener.Close() + return fmt.Errorf("sicheres GUI-Token konnte nicht erzeugt werden: %w", err) + } + base := "/" + hex.EncodeToString(tokenBytes) jobs := &jobStore{jobs: map[string]*GUIJob{}} mux := http.NewServeMux() sub, _ := fs.Sub(guiFiles, "web") + + var activityMu sync.Mutex + lastActivity := time.Now() + touch := func() { activityMu.Lock(); lastActivity = time.Now(); activityMu.Unlock() } + secure := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + touch() + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'") + next.ServeHTTP(w, r) + }) + } + 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+"/" { @@ -102,109 +244,110 @@ func (a *App) runGUI(args []string) error { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } + mux.HandleFunc(base+"/api/ping", func(w http.ResponseWriter, r *http.Request) { + jsonOut(w, map[string]any{"ok": true, "version": appVersion}) + }) mux.HandleFunc(base+"/api/status", func(w http.ResponseWriter, r *http.Request) { st, _ := a.status(false) su, _ := a.selfUpdateStatus(false) - jsonOut(w, GUIStatus{ - Version: appVersion, InstalledVersion: su.Installed, RestartRequired: su.RestartNeeded, - PackageAutoUpdate: packageAutoUpdateActive(), Platform: detectPlatform(), Game: a.gameStatus(), Launcher: st, - }) + jsonOut(w, GUIStatus{Version: appVersion, InstalledVersion: su.Installed, RestartRequired: su.RestartNeeded, + PackageAutoUpdate: packageAutoUpdateActive(), Platform: detectPlatform(), Game: a.gameStatus(), Launcher: st, ActiveJob: jobs.active()}) }) 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() + j := jobs.get(strings.TrimPrefix(r.URL.Path, base+"/api/job/")) if j == nil { http.NotFound(w, r) return } jsonOut(w, j) }) + + var srv *http.Server mux.HandleFunc(base+"/api/action/", func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { + if r.Method != http.MethodPost { http.Error(w, "POST required", 405) return } action := strings.TrimPrefix(r.URL.Path, base+"/api/action/") if action == "restart" { - target := a.selfPath - if installedPackageVersion() != "" { - if _, err := os.Stat("/usr/bin/citizen-launcher"); err == nil { - target = "/usr/bin/citizen-launcher" - } - } + target := a.stableExecutable() if target == "" { http.Error(w, "launcher executable not found", 500) return } - if err := exec.Command(target, "gui").Start(); err != nil { - http.Error(w, err.Error(), 500) - return - } jsonOut(w, map[string]string{"state": "restarting"}) - go func() { time.Sleep(450 * time.Millisecond); os.Exit(0) }() + go func() { + time.Sleep(350 * time.Millisecond) + _ = listener.Close() + if err := syscall.Exec(target, []string{target, "gui"}, os.Environ()); err != nil { + a.logf("GUI restart exec failed: %v", err) + os.Exit(1) + } + }() return } if action == "launch" { + if active := jobs.active(); active != nil { + http.Error(w, friendlyActionName(active.Action)+" läuft noch", 409) + return + } if err := a.gameLaunch(); err != nil { + if errors.Is(err, ErrLauncherAlreadyRunning) { + jsonOut(w, map[string]string{"state": "launcher-running"}) + return + } + if errors.Is(err, ErrGameAlreadyRunning) { + jsonOut(w, map[string]string{"state": "game-running"}) + return + } 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() + + j, err := jobs.start(action) + if err != nil { + http.Error(w, err.Error(), 409) + return + } go func() { - var err error + var runErr 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() + runErr = a.gameInstall() case "repair": - err = a.gameRepair() + runErr = a.gameRepair() case "maintain": - err = a.maintainGamingStack() + runErr = a.maintainGamingStack() case "doctor": - err = a.wineDoctor() + runErr = a.wineDoctor() + case "prepare-system": + runErr = a.ensureSystemPrepared() + case "choose-prefix": + msg, runErr = a.choosePrefixDirectory() case "autopilot-enable": - err = a.enableAutopilot() + runErr = a.enableAutopilot() case "autopilot-disable": - err = a.disableAutopilot() + runErr = a.disableAutopilot() case "support": - var p string - p, err = a.createSupportBundle() - msg = p + msg, runErr = a.createSupportBundle() case "integration-update": - err = a.update(false) + runErr = 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.Message = friendlyActionError(action, err) - j.Error = err.Error() - } else { - j.State = "done" - j.Message = msg + runErr = errors.New("unknown action") } + jobs.finish(j.ID, runErr, msg) }() jsonOut(w, j) }) mux.HandleFunc(base+"/api/open/", func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { + if r.Method != http.MethodPost { http.Error(w, "POST required", 405) return } @@ -219,11 +362,19 @@ func (a *App) runGUI(args []string) error { http.NotFound(w, r) return } - _ = exec.Command("xdg-open", path).Start() + if err := exec.Command("xdg-open", path).Start(); err != nil { + http.Error(w, err.Error(), 500) + return + } jsonOut(w, map[string]string{"state": "opened"}) }) - srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} + + srv = &http.Server{Handler: secure(mux), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second} url := "http://" + listener.Addr().String() + base + "/" + if err := a.writeGUIInfo(guiInstanceInfo{PID: os.Getpid(), URL: url, Started: time.Now().Format(time.RFC3339)}); err != nil { + _ = listener.Close() + return err + } fmt.Println(url) if !noOpen { if err := openGUIURL(url, a.cacheDir); err != nil { @@ -231,16 +382,33 @@ func (a *App) runGUI(args []string) error { return err } } - return srv.Serve(listener) + + // Browser app windows do not give us a portable close notification. Status + // polling acts as a heartbeat. Never stop the backend while a long-running job is active. + go func() { + t := time.NewTicker(30 * time.Second) + defer t.Stop() + for range t.C { + activityMu.Lock() + idle := time.Since(lastActivity) + activityMu.Unlock() + if idle > 10*time.Minute && jobs.active() == nil { + _ = srv.Close() + return + } + } + }() + if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) { + return err + } + return nil } 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 + cmd := exec.Command(p, "--app="+url, "--new-window", "--class=CitizenLauncher") if cmd.Start() == nil { return nil } diff --git a/backend/cmd/citizen-launcher/hardening_test.go b/backend/cmd/citizen-launcher/hardening_test.go new file mode 100644 index 0000000..5cdd53a --- /dev/null +++ b/backend/cmd/citizen-launcher/hardening_test.go @@ -0,0 +1,301 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func testApp(t *testing.T) *App { + t.Helper() + root := t.TempDir() + self := filepath.Join(root, "bin", "citizen-launcher") + if err := os.MkdirAll(filepath.Dir(self), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(self, []byte("test"), 0o755); err != nil { + t.Fatal(err) + } + a := &App{ + home: root, + configDir: filepath.Join(root, ".config", "citizen-launcher"), + stateDir: filepath.Join(root, ".local", "state", "citizen-launcher"), + dataDir: filepath.Join(root, ".local", "share", "citizen-launcher"), + cacheDir: filepath.Join(root, ".cache", "citizen-launcher"), + libDir: filepath.Join(root, ".local", "lib", "citizen-launcher"), + selfPath: self, + } + a.vendorDir = filepath.Join(a.dataDir, "vendor") + return a +} + +func TestDesktopEntryHasRealNewlinesAndStableLaunch(t *testing.T) { + a := testApp(t) + gc := GameConfig{Prefix: filepath.Join(a.home, "Games", "star-citizen")} + if err := a.writeOwnedLaunchFiles(gc); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(a.starDesktopPath()) + if err != nil { + t.Fatal(err) + } + s := string(b) + if strings.Contains(s, `\n`) { + t.Fatalf("desktop contains literal \\n: %q", s) + } + if !strings.Contains(s, "Exec=") || !strings.Contains(s, " game-launch\n") { + t.Fatalf("invalid Exec line: %q", s) + } + wrapper := filepath.Join(a.dataDir, "bin", "star-citizen-launch") + fi, err := os.Stat(wrapper) + if err != nil { + t.Fatal(err) + } + if fi.Mode()&0o111 == 0 { + t.Fatal("launch wrapper is not executable") + } + wb, _ := os.ReadFile(wrapper) + if strings.Contains(string(wb), `\n`) { + t.Fatalf("wrapper contains literal newline escape: %q", string(wb)) + } +} + +func TestDXVKStateRequiresDLLsAndOverrideMarker(t *testing.T) { + a := testApp(t) + prefix := filepath.Join(a.home, "Games", "star-citizen") + if err := os.MkdirAll(filepath.Join(prefix, "drive_c", "windows", "system32"), 0o755); err != nil { + t.Fatal(err) + } + for _, f := range []string{"system.reg", "user.reg"} { + if err := os.WriteFile(filepath.Join(prefix, f), []byte("reg"), 0o600); err != nil { + t.Fatal(err) + } + } + for _, f := range dxvkRequiredDLLs { + if err := os.WriteFile(filepath.Join(prefix, "drive_c", "windows", "system32", f), make([]byte, 2048), 0o644); err != nil { + t.Fatal(err) + } + } + if got := a.dxvkState(prefix, "v3.1"); got != "repair" { + t.Fatalf("without override marker got %q", got) + } + if err := atomicWriteFile(a.dxvkMarkerPath(), []byte("v3.1\n"+prefix+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if got := a.dxvkState(prefix, "v3.1"); got != "ready" { + t.Fatalf("complete DXVK got %q", got) + } + if err := os.Remove(filepath.Join(prefix, "drive_c", "windows", "system32", "dxgi.dll")); err != nil { + t.Fatal(err) + } + if got := a.dxvkState(prefix, "v3.1"); got != "repair" { + t.Fatalf("missing DLL got %q", got) + } +} + +func TestEnvironmentReplacementKeepsManagedWinePath(t *testing.T) { + env := []string{"HOME=/tmp/x", "PATH=/managed/wine/bin:/usr/bin", "LD_LIBRARY_PATH=/managed/lib"} + env = setEnvValue(env, "PATH", "/toolbox/bin:"+envValue(env, "PATH")) + if got := envValue(env, "PATH"); got != "/toolbox/bin:/managed/wine/bin:/usr/bin" { + t.Fatalf("PATH lost runner: %q", got) + } + count := 0 + for _, v := range env { + if strings.HasPrefix(v, "PATH=") { + count++ + } + } + if count != 1 { + t.Fatalf("duplicate PATH entries: %#v", env) + } +} + +func TestSingleInstanceAndStackLocks(t *testing.T) { + a := testApp(t) + first, ok, err := a.acquireGUILock() + if err != nil || !ok { + t.Fatalf("first GUI lock: ok=%v err=%v", ok, err) + } + defer releaseFileLock(first) + second, ok, err := a.acquireGUILock() + if err != nil || ok || second != nil { + t.Fatalf("second GUI lock must be refused: ok=%v err=%v", ok, err) + } + + stack, err := a.stackOperationLock(20 * time.Millisecond) + if err != nil { + t.Fatal(err) + } + defer releaseFileLock(stack) + if other, err := a.stackOperationLock(20 * time.Millisecond); err == nil || other != nil { + t.Fatal("second stack lock unexpectedly succeeded") + } +} + +func TestSupportSanitizer(t *testing.T) { + in := []byte("/home/tester/Games mail=a@example.com ip=192.168.1.4 MAC=aa:bb:cc:dd:ee:ff token=supersecret Bearer abc.def.ghi") + out := string(sanitizeSupport(in, "/home/tester")) + for _, secret := range []string{"/home/tester", "a@example.com", "192.168.1.4", "aa:bb:cc:dd:ee:ff", "supersecret", "Bearer abc.def.ghi"} { + if strings.Contains(out, secret) { + t.Fatalf("sanitizer leaked %q in %q", secret, out) + } + } +} + +func TestReleaseRepoValidation(t *testing.T) { + for _, good := range []string{"owner/repo", "a-b/c_d", "org.name/project.name"} { + if !validReleaseRepo(good) { + t.Fatalf("valid repo rejected: %q", good) + } + } + for _, bad := range []string{"", "owner", "a/b/c", "https://evil/x", "a/../b", "a/b;cmd"} { + if validReleaseRepo(bad) { + t.Fatalf("invalid repo accepted: %q", bad) + } + } +} + +func TestRootUpdaterIgnoresEnvironmentRepository(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("root-only trust-boundary test") + } + t.Setenv("CITIZEN_LAUNCHER_RELEASE_REPO", "evil/redirect") + if got := effectiveReleaseRepo(); got == "evil/redirect" { + t.Fatal("privileged updater trusted user environment") + } +} + +func TestFrontendDoesNotDoubleBindPrimaryAction(t *testing.T) { + b, err := os.ReadFile(filepath.Join("web", "app.js")) + if err != nil { + t.Fatal(err) + } + s := string(b) + if strings.Contains(s, "$('primary').onclick") { + t.Fatal("primary action has a second direct click handler") + } + if !strings.Contains(s, "document.addEventListener('click'") { + t.Fatal("delegated action handler missing") + } +} + +func TestDXVKNativeOverrideSetIsComplete(t *testing.T) { + want := []string{"d3d8", "d3d9", "d3d10core", "d3d11", "dxgi"} + if strings.Join(dxvkOverrideNames, ",") != strings.Join(want, ",") { + t.Fatalf("DXVK override set=%v", dxvkOverrideNames) + } +} + +func TestProcessEnvMatchingIsExact(t *testing.T) { + prefix := "/home/user/Games/star-citizen" + env := []byte("HOME=/home/user\x00WINEPREFIX=" + prefix + "-old\x00PATH=/usr/bin\x00") + if processHasEnvValue(env, "WINEPREFIX", prefix) { + t.Fatal("prefix matcher accepted a longer different prefix") + } + env = append(env, []byte("WINEPREFIX="+prefix+"\x00")...) + if !processHasEnvValue(env, "WINEPREFIX", prefix) { + t.Fatal("prefix matcher missed exact WINEPREFIX") + } +} + +func TestPinnedWinetricksIsIntegrityCheckedAndMinimal(t *testing.T) { + if winetricksVersion != "20260125" { + t.Fatalf("unexpected pinned Winetricks version %q", winetricksVersion) + } + if len(winetricksSHA256) != 64 { + t.Fatalf("Winetricks checksum length=%d", len(winetricksSHA256)) + } + for _, v := range basePrefixWinetricksVerbs { + if strings.EqualFold(v, "powershell") || strings.EqualFold(v, "dxvk") { + t.Fatalf("fragile/duplicate Winetricks verb must not be in base prefix: %s", v) + } + } +} + +func TestDebMigrationRemovesShadowBinaryAndLegacyDesktop(t *testing.T) { + a := testApp(t) + fakeBin := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(fakeBin, 0o755); err != nil { + t.Fatal(err) + } + dpkg := filepath.Join(fakeBin, "dpkg-query") + if err := os.WriteFile(dpkg, []byte("#!/bin/sh\nprintf 'install ok installed\\n1.0.0\\n'\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) + + oldBin := filepath.Join(a.home, ".local", "bin", "citizen-launcher") + if err := os.MkdirAll(filepath.Dir(oldBin), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(oldBin, []byte("#!/bin/sh\necho 0.9.2\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(a.mainDesktopPath()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(a.mainDesktopPath(), []byte("[Desktop Entry]\nName=Citizen Launcher\nExec="+oldBin+" gui\n"), 0o644); err != nil { + t.Fatal(err) + } + + a.migrateUserInstallUnlocked() + if _, err := os.Stat(oldBin); !os.IsNotExist(err) { + t.Fatalf("shadow binary survived migration: %v", err) + } + if _, err := os.Stat(a.mainDesktopPath()); !os.IsNotExist(err) { + t.Fatalf("legacy desktop survived migration: %v", err) + } +} + +func TestRepairDesktopIntegrationFixesLiteralNewlineRegression(t *testing.T) { + a := testApp(t) + gc := a.loadGameConfig() + if err := os.MkdirAll(filepath.Dir(gc.LauncherEXE), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(gc.LauncherEXE, []byte("fixture"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(a.starDesktopPath()), 0o755); err != nil { + t.Fatal(err) + } + bad := "[Desktop Entry]\\nName=Star Citizen\\nExec=/old/star-citizen-launch\\n" + if err := os.WriteFile(a.starDesktopPath(), []byte(bad), 0o644); err != nil { + t.Fatal(err) + } + a.repairDesktopIntegrationUnlocked() + b, err := os.ReadFile(a.starDesktopPath()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), `\\n`) { + t.Fatalf("literal newline regression was not repaired: %q", b) + } + if !strings.Contains(string(b), "Exec=") || !strings.Contains(string(b), " game-launch\n") { + t.Fatalf("repaired desktop invalid: %q", b) + } +} + +func TestCopyFileEnforcesModeOnExistingDestination(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + if err := os.WriteFile(src, []byte("payload"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := copyFile(src, dst, 0o755); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o755 { + t.Fatalf("copy mode=%o want 755", fi.Mode().Perm()) + } +} diff --git a/backend/cmd/citizen-launcher/instance.go b/backend/cmd/citizen-launcher/instance.go new file mode 100644 index 0000000..453f8ce --- /dev/null +++ b/backend/cmd/citizen-launcher/instance.go @@ -0,0 +1,191 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +type guiInstanceInfo struct { + PID int `json:"pid"` + URL string `json:"url"` + Started string `json:"started"` +} + +func (a *App) acquireGUILock() (*os.File, bool, error) { + if err := os.MkdirAll(a.stateDir, 0o755); err != nil { + return nil, false, err + } + f, err := os.OpenFile(filepath.Join(a.stateDir, "gui.lock"), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, false, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = f.Close() + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return nil, false, nil + } + return nil, false, err + } + return f, true, nil +} + +func releaseFileLock(f *os.File) { + if f == nil { + return + } + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() +} + +func (a *App) guiInfoPath() string { return filepath.Join(a.stateDir, "gui-instance.json") } + +func (a *App) writeGUIInfo(info guiInstanceInfo) error { + b, _ := json.MarshalIndent(info, "", " ") + b = append(b, '\n') + return atomicWriteFile(a.guiInfoPath(), b, 0o600) +} + +func (a *App) existingGUIURL() (string, bool) { + for i := 0; i < 12; i++ { + b, err := os.ReadFile(a.guiInfoPath()) + if err == nil { + var info guiInstanceInfo + if json.Unmarshal(b, &info) == nil && info.URL != "" && processAlive(info.PID) { + client := &http.Client{Timeout: 500 * time.Millisecond} + resp, err := client.Get(strings.TrimRight(info.URL, "/") + "/api/ping") + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return info.URL, true + } + } + } + } + time.Sleep(150 * time.Millisecond) + } + return "", false +} + +func processAlive(pid int) bool { + if pid <= 1 { + return false + } + p, err := os.FindProcess(pid) + if err != nil { + return false + } + return p.Signal(syscall.Signal(0)) == nil +} + +func atomicWriteFile(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + name := f.Name() + defer os.Remove(name) + if err := f.Chmod(mode); err != nil { + _ = f.Close() + return err + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(name, path) +} + +var ( + ErrLauncherAlreadyRunning = errors.New("RSI Launcher läuft bereits") + ErrGameAlreadyRunning = errors.New("Star Citizen läuft bereits") +) + +func processHasEnvValue(data []byte, key, value string) bool { + want := key + "=" + value + for _, field := range strings.Split(string(data), "\x00") { + if field == want { + return true + } + } + return false +} + +type prefixProcessState struct { + RSI bool + Game bool +} + +func (a *App) prefixProcessState(prefix string) prefixProcessState { + var state prefixProcessState + entries, err := os.ReadDir("/proc") + if err != nil { + return state + } + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, err := strconv.Atoi(e.Name()); err != nil { + continue + } + env, err := os.ReadFile(filepath.Join("/proc", e.Name(), "environ")) + if err != nil || !processHasEnvValue(env, "WINEPREFIX", prefix) { + continue + } + cmdline, _ := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline")) + cmd := strings.ToLower(strings.ReplaceAll(string(cmdline), "\x00", " ")) + switch { + case strings.Contains(cmd, "starcitizen.exe") || strings.Contains(cmd, "star citizen\\live\\bin64"): + state.Game = true + case strings.Contains(cmd, "rsi launcher") || strings.Contains(cmd, "rsilauncher"): + state.RSI = true + } + if state.RSI && state.Game { + return state + } + } + return state +} + +func (a *App) rsiLauncherRunning(prefix string) bool { return a.prefixProcessState(prefix).RSI } +func (a *App) starCitizenRunning(prefix string) bool { return a.prefixProcessState(prefix).Game } + +func (a *App) stackOperationLock(wait time.Duration) (*os.File, error) { + if err := os.MkdirAll(a.stateDir, 0o755); err != nil { + return nil, err + } + f, err := os.OpenFile(filepath.Join(a.stateDir, "maintenance.lock"), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + deadline := time.Now().Add(wait) + for { + err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return f, nil + } + if time.Now().After(deadline) { + _ = f.Close() + return nil, fmt.Errorf("Gaming-Stack wird gerade gewartet; bitte einen Moment warten") + } + time.Sleep(200 * time.Millisecond) + } +} diff --git a/backend/cmd/citizen-launcher/integration.go b/backend/cmd/citizen-launcher/integration.go new file mode 100644 index 0000000..028fd0b --- /dev/null +++ b/backend/cmd/citizen-launcher/integration.go @@ -0,0 +1,293 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" +) + +const ( + requiredMapCount = int64(16777216) + requiredNoFile = uint64(524288) +) + +var safePrefixPath = regexp.MustCompile(`^[A-Za-z0-9_./-]+$`) + +func validatePrefixPath(prefix string) error { + if prefix == "" || !filepath.IsAbs(prefix) { + return errors.New("Der Wine-Prefix benötigt einen absoluten Linux-Pfad") + } + if filepath.Clean(prefix) != prefix { + return errors.New("Der Installationspfad ist nicht normalisiert") + } + if !safePrefixPath.MatchString(prefix) { + return errors.New("Der Star-Citizen-Pfad darf keine Leerzeichen, Umlaute oder Sonderzeichen enthalten. Empfohlen: ~/Games/star-citizen") + } + if fi, err := os.Lstat(prefix); err == nil && fi.Mode()&os.ModeSymlink != 0 { + return errors.New("Der Wine-Prefix darf kein symbolischer Link sein") + } + return nil +} + +func rotateFile(path string, maxBytes, keepBytes int64) { + if maxBytes <= 0 || keepBytes <= 0 || keepBytes >= maxBytes { + return + } + fi, err := os.Stat(path) + if err != nil || fi.Size() <= maxBytes { + return + } + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + if _, err := f.Seek(-keepBytes, 2); err != nil { + return + } + b, err := io.ReadAll(f) + if err != nil { + return + } + _ = atomicWriteFile(path, b, 0o600) +} + +// stableExecutable returns a path that survives application upgrades. +// Package installs always prefer /usr/bin, user installs prefer ~/.local/bin. +func (a *App) stableExecutable() string { + if installedPackageVersion() != "" { + if fi, err := os.Stat("/usr/bin/citizen-launcher"); err == nil && fi.Mode().IsRegular() { + return "/usr/bin/citizen-launcher" + } + } + userBin := filepath.Join(a.home, ".local", "bin", "citizen-launcher") + if fi, err := os.Stat(userBin); err == nil && fi.Mode().IsRegular() { + return userBin + } + if a.selfPath != "" { + return a.selfPath + } + return filepath.Join(a.libDir, "citizen-launcher") +} + +func desktopExecQuote(s string) string { + // Desktop Entry Exec quoting follows a shell-like double-quoted subset. + repl := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", "$", "\\$") + return `"` + repl.Replace(s) + `"` +} + +func (a *App) userApplicationsDir() string { + return filepath.Join(envOr("XDG_DATA_HOME", filepath.Join(a.home, ".local", "share")), "applications") +} + +func (a *App) mainDesktopPath() string { + return filepath.Join(a.userApplicationsDir(), appID+".desktop") +} + +func (a *App) starDesktopPath() string { + return filepath.Join(a.userApplicationsDir(), "citizen-launcher-star-citizen.desktop") +} + +// migrateUserInstall removes the old ~/.local shadow install when a Debian +// package is now authoritative. It only removes files that identify themselves +// as Citizen Launcher and are not newer than the installed package. +func (a *App) migrateUserInstall() { + if os.Geteuid() == 0 { + return + } + a.migrateUserInstallUnlocked() +} + +func (a *App) migrateUserInstallUnlocked() { + if installedPackageVersion() == "" { + return + } + installed := installedPackageVersion() + oldBin := filepath.Join(a.home, ".local", "bin", "citizen-launcher") + if oldBin != a.selfPath { + if out, err := exec.Command(oldBin, "--version").CombinedOutput(); err == nil { + v := strings.TrimSpace(string(out)) + if v != "" && compareVersions(v, installed) <= 0 { + if err := os.Remove(oldBin); err == nil { + a.logf("removed shadowed legacy user binary %s version=%s", oldBin, v) + } + } + } + } + + // A user-local desktop entry overrides the system package entry. Remove only + // the legacy Citizen Launcher entry, never arbitrary user shortcuts. + if b, err := os.ReadFile(a.mainDesktopPath()); err == nil { + s := string(b) + if strings.Contains(s, "Name=Citizen Launcher") && strings.Contains(s, "citizen-launcher") { + _ = os.Remove(a.mainDesktopPath()) + a.logf("removed legacy user desktop entry so packaged desktop entry can take precedence") + } + } + + // Rewrite old per-user systemd units to the stable package binary if they exist. + service := filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.service") + if b, err := os.ReadFile(service); err == nil && strings.Contains(string(b), ".local/lib/citizen-launcher") { + if err := a.installService(); err != nil { + a.logf("legacy user service migration warning: %v", err) + } + } +} + +// repairDesktopIntegration repairs files created by older versions. It is safe +// to call on every user-level launcher start and does not touch game data. +func (a *App) repairDesktopIntegration() { + if os.Geteuid() == 0 { + return + } + a.repairDesktopIntegrationUnlocked() +} + +func (a *App) repairDesktopIntegrationUnlocked() { + gc := a.loadGameConfig() + if _, err := os.Stat(gc.LauncherEXE); err == nil || gc.InstalledAt != "" { + if err := a.writeOwnedLaunchFiles(gc); err != nil { + a.logf("desktop integration repair warning: %v", err) + } + } +} + +func (a *App) choosePrefixDirectory() (string, error) { + gc := a.loadGameConfig() + if prefixInitialized(gc.Prefix) || gc.LauncherStateReady() { + return "", errors.New("Der Installationsort kann nach der Einrichtung nicht automatisch verschoben werden") + } + start := filepath.Dir(gc.Prefix) + var cmd *exec.Cmd + if p, err := exec.LookPath("kdialog"); err == nil { + cmd = exec.Command(p, "--getexistingdirectory", start, "--title", "Star-Citizen-Installationsordner wählen") + } else if p, err := exec.LookPath("zenity"); err == nil { + cmd = exec.Command(p, "--file-selection", "--directory", "--title=Star-Citizen-Installationsordner wählen", "--filename="+start+string(os.PathSeparator)) + } else { + return "", errors.New("Kein grafischer Ordnerdialog gefunden (kdialog oder zenity)") + } + out, err := cmd.Output() + if err != nil { + return "", errors.New("Ordnerauswahl abgebrochen") + } + parent := strings.TrimSpace(string(out)) + if parent == "" || !filepath.IsAbs(parent) { + return "", errors.New("Ungültiger Installationsordner") + } + prefix := filepath.Clean(filepath.Join(parent, "star-citizen")) + if err := validatePrefixPath(prefix); err != nil { + return "", err + } + gc.Prefix = prefix + gc.GameDir = filepath.Join(prefix, "drive_c", "Program Files", "Roberts Space Industries", "StarCitizen") + gc.LauncherEXE = filepath.Join(prefix, "drive_c", "Program Files", "Roberts Space Industries", "RSI Launcher", "RSI Launcher.exe") + if err := a.saveGameConfig(gc); err != nil { + return "", err + } + return prefix, nil +} + +func (g GameConfig) LauncherStateReady() bool { + _, err := os.Stat(g.LauncherEXE) + return err == nil +} + +// setLaunchNoFile raises the soft file limit for Wine when the session hard +// limit already permits it. Package installations also ship a PAM limits file +// so future sessions get the required hard limit automatically. +func setLaunchNoFile() error { + var lim syscall.Rlimit + if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil { + return err + } + if lim.Max < requiredNoFile { + return fmt.Errorf("Hard-Limit für offene Dateien ist %d, benötigt werden %d", lim.Max, requiredNoFile) + } + if lim.Cur < requiredNoFile { + lim.Cur = requiredNoFile + if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil { + return err + } + } + return nil +} + +func (a *App) prepareSystem(pid int) error { + if os.Geteuid() == 0 { + return prepareSystemRoot(pid) + } + if !commandExists("pkexec") { + return errors.New("Für die einmalige Systemvorbereitung wird Polkit/pkexec benötigt") + } + self := a.stableExecutable() + targetPID := pid + if targetPID <= 1 { + targetPID = os.Getpid() + } + args := []string{self, "prepare-system", "--root", "--pid", strconv.Itoa(targetPID)} + cmd := exec.Command("pkexec", args...) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() +} + +func prepareSystemRoot(targetPID int) error { + if os.Geteuid() != 0 { + return errors.New("prepare-system --root requires root") + } + const sysctlPath = "/etc/sysctl.d/90-citizen-launcher.conf" + const limitsPath = "/etc/security/limits.d/90-citizen-launcher.conf" + if err := os.WriteFile(sysctlPath, []byte("# Citizen Launcher / Star Citizen\nvm.max_map_count = 16777216\n"), 0o644); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(limitsPath), 0o755); err != nil { + return err + } + limits := "# Citizen Launcher / Star Citizen\n* soft nofile 524288\n* hard nofile 524288\n" + if err := os.WriteFile(limitsPath, []byte(limits), 0o644); err != nil { + return err + } + if commandExists("sysctl") { + if out, err := exec.Command("sysctl", "-w", "vm.max_map_count=16777216").CombinedOutput(); err != nil { + return fmt.Errorf("vm.max_map_count: %s", formatCommandFailure(err, out)) + } + } + // Best-effort immediate upgrade of the caller's rlimit. Validate ownership + // using PKEXEC_UID before touching another process. + if targetPID > 1 && commandExists("prlimit") { + uidText := strings.TrimSpace(os.Getenv("PKEXEC_UID")) + if uidText != "" { + status, _ := os.ReadFile(filepath.Join("/proc", strconv.Itoa(targetPID), "status")) + if strings.Contains(string(status), "Uid:\t"+uidText+"\t") { + _ = exec.Command("prlimit", "--pid", strconv.Itoa(targetPID), "--nofile=524288:524288").Run() + } + } + } + return nil +} + +func (a *App) ensureSystemPrepared() error { + r := a.systemReadiness(a.loadGameConfig().Prefix) + if r.MapCountOK && r.NoFileHardOK { + return nil + } + if err := a.prepareSystem(os.Getpid()); err != nil { + return err + } + // vm.max_map_count applies immediately. PAM limits may require a new login, + // but try to raise the current process immediately when permitted. + _ = setLaunchNoFile() + time.Sleep(100 * time.Millisecond) + r = a.systemReadiness(a.loadGameConfig().Prefix) + if !r.MapCountOK { + return fmt.Errorf("vm.max_map_count ist weiterhin zu niedrig (%d)", r.MapCount) + } + return nil +} diff --git a/backend/cmd/citizen-launcher/main.go b/backend/cmd/citizen-launcher/main.go index 45d847d..8c1a346 100644 --- a/backend/cmd/citizen-launcher/main.go +++ b/backend/cmd/citizen-launcher/main.go @@ -16,7 +16,7 @@ import ( ) var ( - appVersion = "0.9.2" + appVersion = "1.0.0" releaseRepo = "sendnwv/omarchy-sc" ) @@ -80,6 +80,10 @@ func main() { fatal(err) } app.rotateLog() + if os.Geteuid() != 0 { + app.migrateUserInstall() + app.repairDesktopIntegration() + } args := os.Args[1:] if len(args) == 0 { @@ -168,12 +172,33 @@ func main() { } case "game-launch": if err := app.gameLaunch(); err != nil { + if errors.Is(err, ErrLauncherAlreadyRunning) || errors.Is(err, ErrGameAlreadyRunning) { + fmt.Println(err) + break + } fatal(err) } case "game-repair": if err := app.gameRepair(); err != nil { fatal(err) } + case "prepare-system": + pid := 0 + for i := 1; i+1 < len(args); i++ { + if args[i] == "--pid" { + fmt.Sscanf(args[i+1], "%d", &pid) + } + } + if hasArg(args[1:], "--root") { + if err := prepareSystemRoot(pid); err != nil { + fatal(err) + } + } else if err := app.prepareSystem(pid); err != nil { + fatal(err) + } + case "migrate-user": + app.migrateUserInstall() + app.repairDesktopIntegration() case "gui": if err := app.runGUI(args[1:]); err != nil { fatal(err) @@ -512,15 +537,12 @@ func (a *App) enableAuto() error { 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 { + if err := a.syncMaintenanceTimer(); err != nil { return err } a.logf("auto updates enabled trusted_remote=%q", st.Remote) @@ -533,7 +555,9 @@ func (a *App) disableAuto() error { c.AutoApply = false c.LastResult = "auto-disabled" }) - _ = run("", "systemctl", "--user", "disable", "--now", "citizen-launcher-maintenance.timer") + if err := a.syncMaintenanceTimer(); err != nil { + return err + } a.logf("auto updates disabled") fmt.Println("disabled") return nil @@ -543,24 +567,17 @@ 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") + backendPath := a.stableExecutable() - 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` + service := "[Unit]\nDescription=Citizen Launcher Autopilot maintenance\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart=" + backendPath + " tick\nNice=10\nIOSchedulingClass=best-effort\nIOSchedulingPriority=7\n\n" + timer := "[Unit]\nDescription=Maintain Citizen Launcher gaming stack automatically\n\n[Timer]\nOnBootSec=10min\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 { + if err := os.WriteFile(servicePath, []byte(service), 0o644); err != nil { return err } - if err := os.WriteFile(timerPath, []byte(strings.ReplaceAll(timer, `\n`, "\n")), 0o644); err != nil { + if err := os.WriteFile(timerPath, []byte(timer), 0o644); err != nil { return err } return run("", "systemctl", "--user", "daemon-reload") @@ -590,7 +607,13 @@ func (a *App) selfSync() error { } same, _ := sameFileHash(source, target) if !same { - tmp := target + ".new" + tmpFile, err := os.CreateTemp(a.libDir, ".citizen-launcher.*.new") + if err != nil { + return err + } + tmp := tmpFile.Name() + _ = tmpFile.Close() + defer os.Remove(tmp) if err := copyFile(source, tmp, 0o755); err != nil { return err } @@ -673,10 +696,7 @@ func (a *App) updateConfig(fn func(*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) - } + _ = atomicWriteFile(a.configPath, data, 0o600) } func (a *App) rotateLog() { @@ -775,6 +795,12 @@ func copyFile(src, dst string, mode os.FileMode) error { out.Close() return err } + // OpenFile's mode is ignored when dst already exists (for example a + // CreateTemp staging file). Always enforce the requested final mode. + if err := out.Chmod(mode); err != nil { + out.Close() + return err + } if err := out.Sync(); err != nil { out.Close() return err diff --git a/backend/cmd/citizen-launcher/preflight.go b/backend/cmd/citizen-launcher/preflight.go new file mode 100644 index 0000000..73f5341 --- /dev/null +++ b/backend/cmd/citizen-launcher/preflight.go @@ -0,0 +1,134 @@ +package main + +import ( + "bufio" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +type SystemReadiness struct { + State string `json:"state"` + MapCount int64 `json:"vm_max_map_count"` + MapCountOK bool `json:"vm_max_map_count_ok"` + NoFileSoft uint64 `json:"nofile_soft"` + NoFileHard uint64 `json:"nofile_hard"` + NoFileHardOK bool `json:"nofile_hard_ok"` + Filesystem string `json:"filesystem,omitempty"` + MountOptions string `json:"mount_options,omitempty"` + FilesystemOK bool `json:"filesystem_ok"` + StorageFreeGiB int `json:"storage_free_gib"` + Reason string `json:"reason,omitempty"` +} + +func (a *App) systemReadiness(prefix string) SystemReadiness { + r := SystemReadiness{State: "ready", FilesystemOK: true} + if b, err := os.ReadFile("/proc/sys/vm/max_map_count"); err == nil { + r.MapCount, _ = strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64) + } + r.MapCountOK = r.MapCount >= requiredMapCount + + var lim syscall.Rlimit + if syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim) == nil { + r.NoFileSoft, r.NoFileHard = lim.Cur, lim.Max + } + r.NoFileHardOK = r.NoFileHard >= requiredNoFile + + existing := nearestExistingPath(prefix) + if existing != "" { + if fs, opts, err := mountInfo(existing); err == nil { + r.Filesystem, r.MountOptions = fs, opts + low := strings.ToLower(fs) + if low == "ntfs" || low == "ntfs3" || low == "fuseblk" || low == "exfat" || optionPresent(opts, "noexec") { + r.FilesystemOK = false + if optionPresent(opts, "noexec") { + r.Reason = "Der Installationsdatenträger ist mit noexec eingehängt. Wine-Runner können dort nicht zuverlässig ausgeführt werden." + } else { + r.Reason = "Star Citizen sollte nicht auf NTFS/exFAT installiert werden. Bitte einen Linux-Datenträger verwenden." + } + } + } + } + var s syscallStatfs + if statfs(prefix, &s) != nil { + _ = statfs(existing, &s) + } + if s.Bsize > 0 { + r.StorageFreeGiB = int((s.Bavail * uint64(s.Bsize)) / (1024 * 1024 * 1024)) + } + + switch { + case !r.FilesystemOK: + r.State = "blocked" + case !r.MapCountOK || !r.NoFileHardOK: + r.State = "prepare" + } + return r +} + +func nearestExistingPath(path string) string { + p := path + for p != "" && p != string(filepath.Separator) { + if _, err := os.Stat(p); err == nil { + return p + } + next := filepath.Dir(p) + if next == p { + break + } + p = next + } + if _, err := os.Stat(string(filepath.Separator)); err == nil { + return string(filepath.Separator) + } + return "" +} + +func mountInfo(path string) (string, string, error) { + if commandExists("findmnt") { + out, err := exec.Command("findmnt", "-T", path, "-n", "-o", "FSTYPE,OPTIONS").CombinedOutput() + if err == nil { + fields := strings.Fields(strings.TrimSpace(string(out))) + if len(fields) >= 2 { + return fields[0], fields[1], nil + } + } + } + // /proc/mounts fallback: choose the longest matching mountpoint. + f, err := os.Open("/proc/mounts") + if err != nil { + return "", "", err + } + defer f.Close() + bestMount, bestFS, bestOpts := "", "", "" + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) < 4 { + continue + } + mount := strings.ReplaceAll(fields[1], `\040`, " ") + if path == mount || strings.HasPrefix(path, strings.TrimRight(mount, "/")+"/") { + if len(mount) > len(bestMount) { + bestMount, bestFS, bestOpts = mount, fields[2], fields[3] + } + } + } + if bestMount == "" { + return "", "", errors.New("mount point not found") + } + return bestFS, bestOpts, nil +} + +func optionPresent(opts, want string) bool { + for _, o := range strings.Split(opts, ",") { + if o == want { + return true + } + } + return false +} diff --git a/backend/cmd/citizen-launcher/selfupdate.go b/backend/cmd/citizen-launcher/selfupdate.go index 074381d..9e423d3 100644 --- a/backend/cmd/citizen-launcher/selfupdate.go +++ b/backend/cmd/citizen-launcher/selfupdate.go @@ -12,6 +12,7 @@ import ( "regexp" "strconv" "strings" + "syscall" "time" ) @@ -36,16 +37,37 @@ func packageAutoUpdateActive() bool { return exec.Command("systemctl", "is-enabled", "citizen-launcher-self-update.timer").Run() == nil } +var releaseRepoPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`) + +func validReleaseRepo(v string) bool { return releaseRepoPattern.MatchString(strings.TrimSpace(v)) } + func effectiveReleaseRepo() string { - if v := strings.TrimSpace(os.Getenv("CITIZEN_LAUNCHER_RELEASE_REPO")); v != "" { - return v - } - if data, err := os.ReadFile("/etc/citizen-launcher/release-repo"); err == nil { - if v := strings.TrimSpace(string(data)); v != "" { + // Never let a user-controlled environment variable redirect the privileged + // system updater. Root trusts only the root-owned config file or compiled repo. + if os.Geteuid() != 0 { + if v := strings.TrimSpace(os.Getenv("CITIZEN_LAUNCHER_RELEASE_REPO")); validReleaseRepo(v) { return v } } - return releaseRepo + if data, err := os.ReadFile("/etc/citizen-launcher/release-repo"); err == nil { + trusted := true + if os.Geteuid() == 0 { + if fi, statErr := os.Stat("/etc/citizen-launcher/release-repo"); statErr != nil { + trusted = false + } else if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 || fi.Mode().Perm()&0o022 != 0 { + trusted = false + } + } + if trusted { + if v := strings.TrimSpace(string(data)); validReleaseRepo(v) { + return v + } + } + } + if validReleaseRepo(releaseRepo) { + return releaseRepo + } + return "sendnwv/omarchy-sc" } func (a *App) selfUpdateStatus(fetch bool) (SelfUpdateStatus, error) { @@ -175,6 +197,9 @@ func (a *App) applySelfUpdate(system, quiet bool) error { if err != nil { return err } + if latestNow := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v"); latestNow != st.Latest { + return fmt.Errorf("Release änderte sich während der Update-Prüfung (%s → %s); Update wird beim nächsten Lauf erneut geprüft", st.Latest, latestNow) + } asset, err := a.releaseAssetForMode(release, st.Latest, st.Mode) if err != nil { return err @@ -231,8 +256,14 @@ func (a *App) applyDebUpdate(asset githubAsset, version string, quiet bool) erro ) cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive") if quiet { - cmd.Stdout = a.logWriter() - cmd.Stderr = a.logWriter() + if os.Geteuid() == 0 { + // The package updater runs with ProtectHome=true. Log to the systemd + // journal instead of trying to write below /root. + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + } else { + cmd.Stdout = a.logWriter() + cmd.Stderr = a.logWriter() + } } else { cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr } @@ -272,8 +303,13 @@ func verifyDebPackage(path, version string) error { if pkg != "citizen-launcher" { return fmt.Errorf("refusing package %q", pkg) } - if compareVersions(pkgVersion, version) < 0 { - return fmt.Errorf("package version %q is older than release %q", pkgVersion, version) + versionOK := pkgVersion == version + if !versionOK { + rev := regexp.MustCompile(`^` + regexp.QuoteMeta(version) + `-[0-9]+$`) + versionOK = rev.MatchString(pkgVersion) + } + if !versionOK { + return fmt.Errorf("package version %q does not match release %q", pkgVersion, version) } if arch != "amd64" { return fmt.Errorf("refusing architecture %q", arch) diff --git a/backend/cmd/citizen-launcher/selfupdate_test.go b/backend/cmd/citizen-launcher/selfupdate_test.go index 51c587c..0d37ab3 100644 --- a/backend/cmd/citizen-launcher/selfupdate_test.go +++ b/backend/cmd/citizen-launcher/selfupdate_test.go @@ -63,3 +63,25 @@ func TestVerifyDebPackageMetadata(t *testing.T) { t.Fatal(err) } } + +func TestVerifyDebPackageRejectsMismatchedNewerVersion(t *testing.T) { + if _, err := exec.LookPath("dpkg-deb"); err != nil { + t.Skip("dpkg-deb unavailable") + } + root := t.TempDir() + pkg := filepath.Join(root, "pkg") + if err := os.MkdirAll(filepath.Join(pkg, "DEBIAN"), 0o755); err != nil { + t.Fatal(err) + } + control := "Package: citizen-launcher\nVersion: 9.9.9\nArchitecture: amd64\nMaintainer: test\nDescription: test\n" + if err := os.WriteFile(filepath.Join(pkg, "DEBIAN", "control"), []byte(control), 0o644); err != nil { + t.Fatal(err) + } + deb := filepath.Join(root, "citizen-launcher_1.0.0_amd64.deb") + if out, err := exec.Command("dpkg-deb", "--build", "--root-owner-group", pkg, deb).CombinedOutput(); err != nil { + t.Fatalf("dpkg-deb: %v: %s", err, out) + } + if err := verifyDebPackage(deb, "1.0.0"); err == nil { + t.Fatal("mismatched package version was accepted") + } +} diff --git a/backend/cmd/citizen-launcher/stack.go b/backend/cmd/citizen-launcher/stack.go index 4b8e43c..252d3ee 100644 --- a/backend/cmd/citizen-launcher/stack.go +++ b/backend/cmd/citizen-launcher/stack.go @@ -1,6 +1,7 @@ package main import ( + "archive/zip" "bufio" "crypto/sha512" "encoding/base64" @@ -18,22 +19,32 @@ import ( "sort" "strconv" "strings" + "syscall" "time" ) const ( - winetricksRepo = "Winetricks/winetricks" - rsiBaseURL = "https://install.robertsspaceindustries.com/rel/2" - rsiLatestYML = rsiBaseURL + "/latest.yml" + winetricksVersion = "20260125" + winetricksSHA256 = "431f82fc74000e6c864409f1d8fb495d696c03928808e3e8acffc45179312a7b" + rsiBaseURL = "https://install.robertsspaceindustries.com/rel/2" + rsiLatestYML = rsiBaseURL + "/latest.yml" ) -// Keep the mandatory prefix small and boring. PowerShell is deliberately not -// a base dependency: current LUG documentation exposes it as a separate -// maintenance/troubleshooting action, and PowerShell Core MSI installation can -// fail under otherwise healthy Wine prefixes. +// Keep Winetricks focused on small, proven prefix tweaks. PowerShell is managed +// separately below because the MSI-based Winetricks path has failed on otherwise +// healthy modern Wine builds. We install the official portable PowerShell Core +// ZIP plus the RSI-compatible PowerShell wrapper instead. var basePrefixWinetricksVerbs = []string{"arial", "tahoma", "win11"} -const wineFileAssociationsKey = "HKEY_CURRENT_USER\\Software\\Wine\\FileOpenAssociations" +const ( + wineFileAssociationsKey = "HKEY_CURRENT_USER\\Software\\Wine\\FileOpenAssociations" + powerShellCoreVersion = "7.4.19" + powerShellCoreURL = "https://github.com/PowerShell/PowerShell/releases/download/v7.4.19/PowerShell-7.4.19-win-x64.zip" + powerShellCoreSHA256 = "cd62ad6d8174cc6fb85b335a0058444bc934fe27c39fa97fe342134286d28af9" + powerShellWrapperVersion = "3.0.5" + powerShellWrapperURL = "https://github.com/ProjectSynchro/powershell-wrapper-for-wine/releases/download/v3.0.5/powershell-wrapper.zip" + powerShellWrapperSHA256 = "08f866265e0395f4bc5ddb18f3dff345771d039a62911e506c62084fd2533ec3" +) type GameConfig struct { Prefix string `json:"prefix"` @@ -46,25 +57,28 @@ type GameConfig struct { } 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"` + BackendVersion string `json:"backend_version"` + Health string `json:"health"` + Hardware string `json:"hardware"` + HardwareReason string `json:"hardware_reason,omitempty"` + GPU string `json:"gpu,omitempty"` + Vulkan string `json:"vulkan,omitempty"` + CPUAVX bool `json:"cpu_avx"` + RAMGiB int `json:"ram_gib"` + CombinedGiB int `json:"combined_gib"` + DiskFreeGiB int `json:"disk_free_gib"` + Prefix string `json:"prefix"` + PrefixState string `json:"prefix_state"` + LauncherState string `json:"launcher_state"` + GameState string `json:"game_state"` + WineVersion string `json:"wine_version,omitempty"` + DXVKVersion string `json:"dxvk_version,omitempty"` + DXVKState string `json:"dxvk_state"` + PowerShellState string `json:"powershell_state"` + LUGVersion string `json:"lug_version,omitempty"` + RSIInstaller string `json:"rsi_installer,omitempty"` + Autopilot bool `json:"autopilot"` + System SystemReadiness `json:"system"` } func (a *App) gameConfigPath() string { return filepath.Join(a.configDir, "game.json") } @@ -101,47 +115,61 @@ func (a *App) saveGameConfig(cfg GameConfig) error { 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()) + return atomicWriteFile(a.gameConfigPath(), data, 0o600) } func (a *App) gameStatus() GameStatus { gc := a.loadGameConfig() uc := a.loadConfig() hs := a.hardwareStatus(gc.Prefix) + sys := a.systemReadiness(gc.Prefix) st := GameStatus{ - BackendVersion: appVersion, - Health: "setup", - Hardware: hs.State, - HardwareReason: hs.Reason, - GPU: hs.GPU, - Vulkan: hs.Vulkan, - CPUAVX: hs.AVX, - RAMGiB: hs.RAMGiB, - CombinedGiB: hs.CombinedGiB, - DiskFreeGiB: hs.DiskFreeGiB, - Prefix: gc.Prefix, - PrefixState: "missing", - LauncherState: "missing", - GameState: "missing", - WineVersion: readMetaVersion(filepath.Join(a.vendorDir, "wine", "meta.json")), - DXVKVersion: readMetaVersion(filepath.Join(a.vendorDir, "dxvk", "meta.json")), - LUGVersion: readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json")), - RSIInstaller: gc.RSIInstaller, - Autopilot: uc.AutoMaintain, + BackendVersion: appVersion, + Health: "setup", + Hardware: hs.State, + HardwareReason: hs.Reason, + GPU: hs.GPU, + Vulkan: hs.Vulkan, + CPUAVX: hs.AVX, + RAMGiB: hs.RAMGiB, + CombinedGiB: hs.CombinedGiB, + DiskFreeGiB: hs.DiskFreeGiB, + Prefix: gc.Prefix, + PrefixState: "missing", + LauncherState: "missing", + GameState: "missing", + WineVersion: readMetaVersion(filepath.Join(a.vendorDir, "wine", "meta.json")), + DXVKVersion: readMetaVersion(filepath.Join(a.vendorDir, "dxvk", "meta.json")), + DXVKState: "missing", + PowerShellState: "missing", + LUGVersion: readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json")), + RSIInstaller: gc.RSIInstaller, + Autopilot: uc.AutoMaintain, + System: sys, + } + if err := validatePrefixPath(gc.Prefix); err != nil { + st.Health = "hardware-blocked" + st.HardwareReason = err.Error() + return st } if hs.State == "blocked" { st.Health = "hardware-blocked" return st } + if sys.State == "blocked" { + st.Health = "hardware-blocked" + st.HardwareReason = sys.Reason + return st + } if prefixInitialized(gc.Prefix) { st.PrefixState = "ready" } else if fi, err := os.Stat(gc.Prefix); err == nil && fi.IsDir() { st.PrefixState = "partial" } + if st.PrefixState == "ready" { + st.DXVKState = a.dxvkState(gc.Prefix, st.DXVKVersion) + st.PowerShellState = a.powerShellState(gc.Prefix) + } if _, err := os.Stat(gc.LauncherEXE); err == nil { st.LauncherState = "ready" } @@ -151,13 +179,28 @@ func (a *App) gameStatus() GameStatus { st.GameState = "partial" } + // Around 150 GiB of free space is the current LUG quick-start recommendation + // for a fresh install. Do not block an already-installed game because free + // space naturally drops after installation. + if st.GameState == "missing" && sys.StorageFreeGiB > 0 && sys.StorageFreeGiB < 150 { + st.Health = "hardware-blocked" + st.HardwareReason = fmt.Sprintf("Für eine Neuinstallation sind ungefähr 150 GiB freier Speicher empfohlen; erkannt wurden %d GiB.", sys.StorageFreeGiB) + return st + } + switch { + case sys.State == "prepare": + st.Health = "system-prepare" case st.WineVersion == "": st.Health = "setup" case st.PrefixState == "partial": st.Health = "repair" case st.PrefixState != "ready": st.Health = "install" + case st.DXVKState != "ready": + st.Health = "repair" + case st.PowerShellState != "ready": + st.Health = "repair" case st.LauncherState != "ready": st.Health = "launcher-repair" case st.GameState != "ready": @@ -206,9 +249,12 @@ func (a *App) hardwareStatus(prefix string) hardwareInfo { } h.RAMGiB = int(vals["MemTotal"] / (1024 * 1024)) h.CombinedGiB = int((vals["MemTotal"] + vals["SwapTotal"]) / (1024 * 1024)) - if h.RAMGiB < 14 && h.State != "blocked" { + if h.RAMGiB < 16 && h.State != "blocked" { h.State = "blocked" h.Reason = "Weniger als 16 GiB RAM erkannt." + } else if h.CombinedGiB < 48 && h.State != "blocked" { + h.State = "blocked" + h.Reason = fmt.Sprintf("RAM + Swap/ZRAM ergeben %d GiB; für Star Citizen werden mindestens 48 GiB benötigt.", h.CombinedGiB) } } var stat syscallStatfs @@ -251,12 +297,33 @@ func (a *App) hardwareStatus(prefix string) hardwareInfo { 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") { + text := string(out) + low := strings.ToLower(text) + if err != nil || strings.Contains(text, "VK_ERROR_INCOMPATIBLE_DRIVER") { h.State = "blocked" h.Vulkan = "blocked" h.Reason = "Vulkan ist nicht funktionsfähig." return h } + if strings.Contains(low, "device_type_cpu") || strings.Contains(low, "llvmpipe") || strings.Contains(low, "lavapipe") || strings.Contains(low, "softpipe") { + h.State = "blocked" + h.Vulkan = "blocked" + h.Reason = "Es wurde nur ein Software-Vulkan-Gerät erkannt. Star Citizen benötigt eine echte Vulkan-GPU." + return h + } + if m := regexp.MustCompile(`(?m)deviceName\s*=\s*(.+)$`).FindStringSubmatch(text); len(m) == 2 { + h.GPU = strings.TrimSpace(m[1]) + } + if m := regexp.MustCompile(`(?m)apiVersion\s*=\s*([0-9]+)\.([0-9]+)`).FindStringSubmatch(text); len(m) == 3 { + major, _ := strconv.Atoi(m[1]) + minor, _ := strconv.Atoi(m[2]) + if major < 1 || (major == 1 && minor < 3) { + h.State = "blocked" + h.Vulkan = "blocked" + h.Reason = fmt.Sprintf("Vulkan %d.%d erkannt; der aktuelle DXVK-Stack benötigt Vulkan 1.3 oder neuer.", major, minor) + return h + } + } h.Vulkan = "ready" } else { matches, _ := filepath.Glob("/usr/share/vulkan/icd.d/*.json") @@ -308,13 +375,44 @@ func (a *App) currentRunner() (string, error) { return p, nil } +func envValue(env []string, key string) string { + for i := len(env) - 1; i >= 0; i-- { + k, v, ok := strings.Cut(env[i], "=") + if ok && k == key { + return v + } + } + return "" +} + +func setEnvValue(env []string, key, value string) []string { + env = environmentWithout(env, key) + return append(env, key+"="+value) +} + +func environmentWithout(env []string, keys ...string) []string { + remove := map[string]bool{} + for _, k := range keys { + remove[k] = true + } + out := make([]string, 0, len(env)) + for _, item := range env { + k, _, ok := strings.Cut(item, "=") + if ok && remove[k] { + continue + } + out = append(out, item) + } + return out +} + func (a *App) runnerEnv(prefix string) ([]string, string, error) { runner, err := a.currentRunner() if err != nil { return nil, "", err } bin := filepath.Join(runner, "bin") - env := append([]string{}, os.Environ()...) + env := environmentWithout(os.Environ(), "SDL_VIDEODRIVER", "WINE", "WINESERVER", "WINEPREFIX", "WINEARCH", "WINEDEBUG", "WINEDLLOVERRIDES") env = append(env, "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "WINE="+filepath.Join(bin, "wine"), @@ -394,42 +492,50 @@ func (a *App) toolsEnv(base []string) ([]string, error) { 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") + oldPath := envValue(env, "PATH") + newPath := strings.Join(dirs, string(os.PathListSeparator)) + if oldPath != "" { + newPath += string(os.PathListSeparator) + oldPath + } + env = setEnvValue(env, "PATH", newPath) + existingLD := envValue(env, "LD_LIBRARY_PATH") ld := strings.Join(libdirs, string(os.PathListSeparator)) if existingLD != "" { ld += string(os.PathListSeparator) + existingLD } - env = append(env, "LD_LIBRARY_PATH="+ld) + env = setEnvValue(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") - } + // Pin the exact Winetricks build used by this Citizen Launcher release. + // Gaming-stack updates should be deterministic: a future Winetricks release + // must first pass our regression tests before a Launcher release adopts it. + tag := winetricksVersion dir := filepath.Join(a.vendorDir, "winetricks", tag) target := filepath.Join(dir, "winetricks") - if _, err := os.Stat(target); err == nil { + if err := verifySHA256Hex(target, winetricksSHA256); err == nil { + _ = os.Chmod(target, 0o755) return target, tag, nil } if err := os.MkdirAll(dir, 0o755); err != nil { return "", "", err } u := "https://raw.githubusercontent.com/Winetricks/winetricks/refs/tags/" + tag + "/src/winetricks" - if err := download(u, target+".new"); err != nil { + tmp := target + ".new" + _ = os.Remove(tmp) + if err := download(u, tmp); err != nil { return "", "", err } - if err := os.Chmod(target+".new", 0o755); err != nil { + if err := verifySHA256Hex(tmp, winetricksSHA256); err != nil { + _ = os.Remove(tmp) + return "", "", fmt.Errorf("Winetricks Integritätsprüfung: %w", err) + } + if err := os.Chmod(tmp, 0o755); err != nil { + _ = os.Remove(tmp) return "", "", err } - if err := os.Rename(target+".new", target); err != nil { + if err := os.Rename(tmp, target); err != nil { return "", "", err } return target, tag, nil @@ -579,16 +685,36 @@ func (a *App) backupIncompletePrefix(gc GameConfig) (GameConfig, string, error) } func (a *App) gameInstall() error { - unlock, err := a.acquireMaintenanceLock() + lock, err := a.stackOperationLock(2 * time.Second) if err != nil { return err } - defer unlock() + defer releaseFileLock(lock) + return a.gameInstallUnlocked() +} + +func (a *App) gameInstallUnlocked() error { gc := a.loadGameConfig() + if err := validatePrefixPath(gc.Prefix); err != nil { + return err + } + if prefixInitialized(gc.Prefix) && prefixBusy(gc.Prefix) { + return errors.New("Der Star-Citizen-Wine-Prefix wird gerade verwendet. Bitte RSI Launcher, Wine-Konfiguration und Spiel schließen und erneut versuchen.") + } hs := a.hardwareStatus(gc.Prefix) if hs.State == "blocked" { return fmt.Errorf("hardware nicht spielbereit: %s", hs.Reason) } + if sys := a.systemReadiness(gc.Prefix); sys.State == "blocked" { + return errors.New(sys.Reason) + } else if sys.State == "prepare" { + if err := a.ensureSystemPrepared(); err != nil { + return fmt.Errorf("Systemvorbereitung: %w", err) + } + } + if sys := a.systemReadiness(gc.Prefix); sys.StorageFreeGiB > 0 && sys.StorageFreeGiB < 150 { + return fmt.Errorf("zu wenig freier Speicher: %d GiB; für eine Neuinstallation werden ungefähr 150 GiB empfohlen", sys.StorageFreeGiB) + } // LUG is not part of the installation control path. A portable runtime is // fetched lazily only when required system tools are missing. @@ -618,6 +744,9 @@ func (a *App) gameInstall() error { if err := a.ensurePrefixComponents(gc); err != nil { return err } + if err := a.syncDXVK(); err != nil { + return fmt.Errorf("DXVK: %w", err) + } if err := a.ensureRSILauncher(gc, true); err != nil { return err } @@ -627,9 +756,6 @@ func (a *App) gameInstall() error { 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")) @@ -659,7 +785,9 @@ func (a *App) initializePrefix(gc GameConfig) error { } wait := exec.Command(filepath.Join(bin, "wineserver"), "-w") wait.Env = env - _, _ = wait.CombinedOutput() + if waitOut, waitErr := wait.CombinedOutput(); waitErr != nil { + return fmt.Errorf("Wine-Prefix Abschluss: %s", formatCommandFailure(waitErr, waitOut)) + } deadline := time.Now().Add(30 * time.Second) for !prefixInitialized(gc.Prefix) && time.Now().Before(deadline) { time.Sleep(300 * time.Millisecond) @@ -697,6 +825,13 @@ func (a *App) ensurePrefixComponents(gc GameConfig) error { } } + // RSI Launcher 2.x uses Windows PowerShell for installer support, game + // directory setup and verification. Avoid the fragile MSI path entirely: + // install a verified portable PowerShell Core plus the Wine wrapper. + if err := a.ensurePowerShell(gc, env); err != nil { + return fmt.Errorf("PowerShell-Kompatibilität: %w", err) + } + runner, _ := a.currentRunner() wine := filepath.Join(runner, "bin", "wine") // This is a convenience tweak only: prevent Wine from creating host file @@ -708,10 +843,300 @@ func (a *App) ensurePrefixComponents(gc GameConfig) error { if regErr != nil { a.logf("non-fatal Wine registry association tweak warning: %s", formatCommandFailure(regErr, out)) } + // Let Wine finish registry writes before DXVK changes the same prefix. + wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w") + wait.Env = env + if waitOut, waitErr := wait.CombinedOutput(); waitErr != nil { + return fmt.Errorf("Wine-Prefix Abschluss: %s", formatCommandFailure(waitErr, waitOut)) + } _ = writeMeta(filepath.Join(a.vendorDir, "winetricks", "meta.json"), componentMeta{Version: tag, Updated: time.Now().Format(time.RFC3339)}) return nil } +func (a *App) powerShellPaths(prefix string) (core, profile, wrapper64, wrapper32 string) { + coreDir := filepath.Join(prefix, "drive_c", "Program Files", "PowerShell", "7") + return filepath.Join(coreDir, "pwsh.exe"), filepath.Join(coreDir, "profile.ps1"), + filepath.Join(prefix, "drive_c", "windows", "system32", "WindowsPowerShell", "v1.0", "powershell.exe"), + filepath.Join(prefix, "drive_c", "windows", "syswow64", "WindowsPowerShell", "v1.0", "powershell.exe") +} + +func (a *App) powerShellState(prefix string) string { + core, profile, wrapper64, wrapper32 := a.powerShellPaths(prefix) + for _, path := range []string{core, profile, wrapper64, wrapper32} { + fi, err := os.Stat(path) + if err != nil || fi.IsDir() || fi.Size() < 1024 { + return "repair" + } + } + marker := readText(filepath.Join(a.vendorDir, "powershell", "marker.txt")) + managed := powerShellCoreVersion + "+wrapper-" + powerShellWrapperVersion + "\n" + prefix + adopted := "verified\n" + prefix + if marker != managed && marker != adopted { + return "repair" + } + return "ready" +} + +func (a *App) writePowerShellMarker(prefix, version string) error { + markerDir := filepath.Join(a.vendorDir, "powershell") + if err := os.MkdirAll(markerDir, 0o755); err != nil { + return err + } + return atomicWriteFile(filepath.Join(markerDir, "marker.txt"), []byte(version+"\n"+prefix+"\n"), 0o600) +} + +func (a *App) probePowerShell(gc GameConfig, env []string) error { + _, _, wrapper64, _ := a.powerShellPaths(gc.Prefix) + runner, err := a.currentRunner() + if err != nil { + return err + } + wine := filepath.Join(runner, "bin", "wine") + probe := exec.Command(wine, wrapper64, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Write-Output CITIZEN_POWERSHELL_OK") + probe.Env = env + out, err := probe.CombinedOutput() + if err != nil || !strings.Contains(string(out), "CITIZEN_POWERSHELL_OK") { + return fmt.Errorf("PowerShell Selbsttest: %s", formatCommandFailure(err, out)) + } + wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w") + wait.Env = env + if out, err := wait.CombinedOutput(); err != nil { + return fmt.Errorf("PowerShell Abschluss: %s", formatCommandFailure(err, out)) + } + return nil +} + +func verifySHA256Hex(path, want string) error { + got, err := fileHash(path) + if err != nil { + return err + } + if !strings.EqualFold(strings.TrimSpace(got), strings.TrimSpace(want)) { + return fmt.Errorf("SHA-256 mismatch for %s", filepath.Base(path)) + } + return nil +} + +func extractZipSafe(path, dir string) error { + r, err := zip.OpenReader(path) + if err != nil { + return err + } + defer r.Close() + cleanRoot, err := filepath.Abs(dir) + if err != nil { + return err + } + if err := os.MkdirAll(cleanRoot, 0o755); err != nil { + return err + } + for _, zf := range r.File { + name := filepath.Clean(filepath.FromSlash(zf.Name)) + if name == "." || filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, ".."+string(os.PathSeparator)) { + return fmt.Errorf("unsicherer ZIP-Pfad: %q", zf.Name) + } + target := filepath.Join(cleanRoot, name) + rel, err := filepath.Rel(cleanRoot, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return fmt.Errorf("ZIP-Pfad verlässt Zielverzeichnis: %q", zf.Name) + } + if zf.FileInfo().Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("Symlink im ZIP wird nicht akzeptiert: %q", zf.Name) + } + if zf.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := zf.Open() + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + rc.Close() + return err + } + _, copyErr := io.Copy(out, io.LimitReader(rc, 512<<20)) + closeErr := out.Close() + rc.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + } + return nil +} + +func (a *App) ensurePowerShell(gc GameConfig, env []string) error { + if a.powerShellState(gc.Prefix) == "ready" { + return nil + } + // Adopt a working existing Winetricks/manual installation instead of + // overwriting it merely because Citizen Launcher did not create it. + core, profile, wrapper64Existing, wrapper32Existing := a.powerShellPaths(gc.Prefix) + allPresent := true + for _, path := range []string{core, profile, wrapper64Existing, wrapper32Existing} { + fi, err := os.Stat(path) + if err != nil || fi.IsDir() || fi.Size() < 1024 { + allPresent = false + break + } + } + if allPresent { + if err := a.probePowerShell(gc, env); err == nil { + if err := a.writePowerShellMarker(gc.Prefix, "verified"); err != nil { + return err + } + a.logf("adopted existing working PowerShell compatibility layer") + return nil + } + } + + cacheDir := filepath.Join(a.cacheDir, "powershell") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return err + } + coreArchive := filepath.Join(cacheDir, "PowerShell-"+powerShellCoreVersion+"-win-x64.zip") + wrapperArchive := filepath.Join(cacheDir, "powershell-wrapper-"+powerShellWrapperVersion+".zip") + for _, asset := range []struct{ url, path, sha string }{ + {powerShellCoreURL, coreArchive, powerShellCoreSHA256}, + {powerShellWrapperURL, wrapperArchive, powerShellWrapperSHA256}, + } { + if err := verifySHA256Hex(asset.path, asset.sha); err != nil { + _ = os.Remove(asset.path) + if err := download(asset.url, asset.path+".part"); err != nil { + _ = os.Remove(asset.path + ".part") + return err + } + if err := verifySHA256Hex(asset.path+".part", asset.sha); err != nil { + _ = os.Remove(asset.path + ".part") + return err + } + if err := os.Rename(asset.path+".part", asset.path); err != nil { + return err + } + } + } + + stage, err := os.MkdirTemp(a.cacheDir, "powershell-stage-") + if err != nil { + return err + } + defer os.RemoveAll(stage) + coreStage := filepath.Join(stage, "core") + wrapperStage := filepath.Join(stage, "wrapper") + if err := extractZipSafe(coreArchive, coreStage); err != nil { + return fmt.Errorf("PowerShell Core entpacken: %w", err) + } + if err := extractZipSafe(wrapperArchive, wrapperStage); err != nil { + return fmt.Errorf("PowerShell Wrapper entpacken: %w", err) + } + if _, err := os.Stat(filepath.Join(coreStage, "pwsh.exe")); err != nil { + return errors.New("PowerShell Core ZIP enthält pwsh.exe nicht") + } + + coreTarget := filepath.Join(gc.Prefix, "drive_c", "Program Files", "PowerShell", "7") + _, _, wrapper64Target, wrapper32Target := a.powerShellPaths(gc.Prefix) + managedTargets := []string{coreTarget, wrapper64Target, wrapper32Target} + backups := make(map[string]string, len(managedTargets)) + for _, target := range managedTargets { + backup := target + ".citizen-backup" + _ = os.RemoveAll(backup) + if _, err := os.Lstat(target); err == nil { + if err := os.Rename(target, backup); err != nil { + for original, saved := range backups { + _ = os.Rename(saved, original) + } + return fmt.Errorf("vorhandene PowerShell-Datei sichern: %w", err) + } + backups[target] = backup + } + } + restore := true + defer func() { + if !restore { + return + } + for _, target := range managedTargets { + _ = os.RemoveAll(target) + } + for original, saved := range backups { + _ = os.Rename(saved, original) + } + }() + if err := copyTree(coreStage, coreTarget); err != nil { + return fmt.Errorf("PowerShell Core installieren: %w", err) + } + + findWrapper := func(bits string) (string, error) { + candidates := []string{ + filepath.Join(wrapperStage, bits, "powershell.exe"), + filepath.Join(wrapperStage, "powershell"+bits+".exe"), + } + for _, p := range candidates { + if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 1024 { + return p, nil + } + } + return "", fmt.Errorf("%s-bit PowerShell Wrapper fehlt", bits) + } + wrapper64, err := findWrapper("64") + if err != nil { + return err + } + wrapper32, err := findWrapper("32") + if err != nil { + return err + } + profileSource := filepath.Join(wrapperStage, "profile.ps1") + if _, err := os.Stat(profileSource); err != nil { + return errors.New("PowerShell Wrapper profile.ps1 fehlt") + } + for _, pair := range [][2]string{{wrapper64, wrapper64Target}, {wrapper32, wrapper32Target}, {profileSource, filepath.Join(coreTarget, "profile.ps1")}} { + if err := os.MkdirAll(filepath.Dir(pair[1]), 0o755); err != nil { + return err + } + if err := copyFile(pair[0], pair[1], 0o644); err != nil { + return err + } + } + + runner, err := a.currentRunner() + if err != nil { + return err + } + wine := filepath.Join(runner, "bin", "wine") + reg := exec.Command(wine, "reg", "add", `HKEY_CURRENT_USER\Software\Wine\DllOverrides`, "/v", "powershell.exe", "/t", "REG_SZ", "/d", "native,builtin", "/f") + reg.Env = env + if out, err := reg.CombinedOutput(); err != nil { + return fmt.Errorf("PowerShell DLL-Override: %s", formatCommandFailure(err, out)) + } + wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w") + wait.Env = env + if out, err := wait.CombinedOutput(); err != nil { + return fmt.Errorf("PowerShell Registry Abschluss: %s", formatCommandFailure(err, out)) + } + if err := a.probePowerShell(gc, env); err != nil { + return err + } + if err := a.writePowerShellMarker(gc.Prefix, powerShellCoreVersion+"+wrapper-"+powerShellWrapperVersion); err != nil { + return err + } + restore = false + for _, saved := range backups { + _ = os.RemoveAll(saved) + } + a.logf("PowerShell compatibility ready core=%s wrapper=%s", powerShellCoreVersion, powerShellWrapperVersion) + return nil +} + func (a *App) ensureRSILauncher(gc GameConfig, forceIfMissing bool) error { file, u, sha, err := a.latestRSIInstaller() if err != nil { @@ -775,26 +1200,37 @@ func (a *App) writeOwnedLaunchFiles(gc GameConfig) error { if err := os.MkdirAll(binDir, 0o755); err != nil { return err } + stable := a.stableExecutable() 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 { + content := "#!/usr/bin/env bash\nexec " + shellQuote(stable) + " game-launch \"$@\"\n" + if err := atomicWriteFile(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 { + + apps := a.userApplicationsDir() + if err := os.MkdirAll(apps, 0o755); err != nil { return err } + desktop := "[Desktop Entry]\n" + + "Name=Star Citizen\n" + + "Comment=Star Citizen via Citizen Launcher\n" + + "Type=Application\n" + + "Categories=Game;\n" + + "Terminal=false\n" + + "StartupNotify=true\n" + + "StartupWMClass=RSI Launcher.exe\n" + + "Icon=citizen-launcher\n" + + "TryExec=" + stable + "\n" + + "Exec=" + desktopExecQuote(stable) + " game-launch\n" + if strings.Contains(desktop, `\\n`) { + return errors.New("internal desktop-entry newline error") + } + if err := atomicWriteFile(a.starDesktopPath(), []byte(desktop), 0o644); err != nil { + return err + } + if commandExists("update-desktop-database") { + _ = exec.Command("update-desktop-database", apps).Run() + } return nil } @@ -814,28 +1250,68 @@ func (a *App) writeLUGCompatibilityConfig(gc GameConfig) error { func (a *App) gameLaunch() error { gc := a.loadGameConfig() + if err := validatePrefixPath(gc.Prefix); err != nil { + return err + } + state := a.prefixProcessState(gc.Prefix) + if state.Game { + return ErrGameAlreadyRunning + } + if state.RSI { + return ErrLauncherAlreadyRunning + } + + lock, err := a.stackOperationLock(8 * time.Second) + if err != nil { + return err + } + defer releaseFileLock(lock) + + state = a.prefixProcessState(gc.Prefix) + if state.Game { + return ErrGameAlreadyRunning + } + if state.RSI { + return ErrLauncherAlreadyRunning + } + if prefixBusy(gc.Prefix) { + return errors.New("Der Wine-Prefix wird bereits von einem anderen Prozess verwendet. Bitte laufende Wine-Werkzeuge schließen und erneut versuchen.") + } + st := a.gameStatus() - // 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. + triggerMaintenance := false if cfg := a.loadConfig(); cfg.AutoMaintain { - stale := true + triggerMaintenance = 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() + triggerMaintenance = time.Since(t) > 6*time.Hour } } if st.Hardware == "blocked" { return errors.New(st.HardwareReason) } + if st.System.State == "blocked" { + return errors.New(st.System.Reason) + } + if st.System.State == "prepare" { + if err := a.ensureSystemPrepared(); err != nil { + return fmt.Errorf("Systemvorbereitung: %w", err) + } + } + if err := setLaunchNoFile(); err != nil { + a.logf("launch nofile warning: %v", err) + } if !prefixInitialized(gc.Prefix) { return errors.New("Wine-Prefix fehlt; zuerst Setup ausführen") } if _, err := os.Stat(gc.LauncherEXE); err != nil { return errors.New("RSI Launcher fehlt; Reparatur ausführen") } + if st.DXVKState != "ready" { + return errors.New("DXVK ist nicht vollständig aktiviert; bitte Automatisch reparieren ausführen") + } + if st.PowerShellState != "ready" { + return errors.New("Die RSI-PowerShell-Kompatibilität ist unvollständig; bitte Automatisch reparieren ausführen") + } env, runner, err := a.runnerEnv(gc.Prefix) if err != nil { return err @@ -844,39 +1320,102 @@ func (a *App) gameLaunch() error { "__GL_SHADER_DISK_CACHE=1", "__GL_SHADER_DISK_CACHE_SIZE=10737418240", "__GL_SHADER_DISK_CACHE_PATH="+gc.Prefix, + "__GL_SHADER_DISK_CACHE_SKIP_CLEANUP=1", "MESA_SHADER_CACHE_DIR="+gc.Prefix, "MESA_SHADER_CACHE_MAX_SIZE=10G", ) + + // A running game/launcher was ruled out above. Clearing the dedicated + // wineserver here only removes stale prefix processes from previous crashes. + ws := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-k") + ws.Env = env + _, _ = ws.CombinedOutput() + log := filepath.Join(a.stateDir, "rsi-launcher.log") _ = os.MkdirAll(a.stateDir, 0o755) + rotateFile(log, 8*1024*1024, 4*1024*1024) f, err := os.OpenFile(log, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return err } cmd := exec.Command(filepath.Join(runner, "bin", "wine"), gc.LauncherEXE) cmd.Env = env + cmd.Dir = filepath.Dir(gc.LauncherEXE) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Stdout = f cmd.Stderr = f if err := cmd.Start(); err != nil { - f.Close() + _ = f.Close() return err } _ = f.Close() - a.logf("RSI Launcher started pid=%d", cmd.Process.Pid) - return nil + waitCh := make(chan error, 1) + go func() { waitCh <- cmd.Wait() }() + + // Wine may hand off to the Windows child and let the wrapper process exit. + // Treat the launch as successful when the actual RSI process appears. + deadline := time.Now().Add(10 * time.Second) + var wrapperErr error + for time.Now().Before(deadline) { + if a.rsiLauncherRunning(gc.Prefix) { + a.logf("RSI Launcher started pid=%d", cmd.Process.Pid) + if triggerMaintenance && !detectPlatform().SystemdUser { + stable := a.stableExecutable() + go func() { + time.Sleep(1500 * time.Millisecond) + _ = exec.Command(stable, "maintain").Start() + }() + } + return nil + } + select { + case wrapperErr = <-waitCh: + if !a.rsiLauncherRunning(gc.Prefix) { + if wrapperErr != nil { + return fmt.Errorf("RSI Launcher wurde früh beendet: %v. Details: %s", wrapperErr, log) + } + return fmt.Errorf("RSI Launcher wurde beendet, bevor sein Prozess sichtbar wurde. Details: %s", log) + } + default: + } + time.Sleep(250 * time.Millisecond) + } + if a.rsiLauncherRunning(gc.Prefix) { + return nil + } + return fmt.Errorf("RSI Launcher wurde gestartet, hat aber innerhalb von 10 Sekunden keinen laufenden Launcher-Prozess erzeugt. Details: %s", log) } func (a *App) gameRepair() error { + lock, err := a.stackOperationLock(2 * time.Second) + if err != nil { + return err + } + defer releaseFileLock(lock) + gc := a.loadGameConfig() + if err := validatePrefixPath(gc.Prefix); err != nil { + return err + } + if prefixInitialized(gc.Prefix) && prefixBusy(gc.Prefix) { + return errors.New("Reparatur pausiert: RSI Launcher, Star Citizen oder ein anderes Wine-Werkzeug verwendet den Prefix noch.") + } hs := a.hardwareStatus(gc.Prefix) if hs.State == "blocked" { return errors.New(hs.Reason) } + if sys := a.systemReadiness(gc.Prefix); sys.State == "blocked" { + return errors.New(sys.Reason) + } else if sys.State == "prepare" { + if err := a.ensureSystemPrepared(); err != nil { + return err + } + } if err := a.syncWineRunner(); err != nil { return err } if !prefixInitialized(gc.Prefix) { - return a.gameInstall() + return a.gameInstallUnlocked() } if err := a.ensurePrefixComponents(gc); err != nil { return err diff --git a/backend/cmd/citizen-launcher/stack_test.go b/backend/cmd/citizen-launcher/stack_test.go index 307d677..330486e 100644 --- a/backend/cmd/citizen-launcher/stack_test.go +++ b/backend/cmd/citizen-launcher/stack_test.go @@ -1,6 +1,9 @@ package main import ( + "archive/zip" + "os" + "path/filepath" "strings" "testing" ) @@ -49,10 +52,10 @@ func TestParseRSILatestVersionFallback(t *testing.T) { } } -func TestBasePrefixDoesNotRequirePowerShell(t *testing.T) { +func TestBasePrefixDoesNotUseFragilePowerShellMSI(t *testing.T) { for _, v := range basePrefixWinetricksVerbs { if strings.EqualFold(v, "powershell") { - t.Fatalf("PowerShell must remain optional") + t.Fatalf("PowerShell must be managed by the verified portable compatibility layer, not Winetricks/MSI") } } } @@ -65,3 +68,114 @@ func TestWineRegistryKeyUsesSingleSeparators(t *testing.T) { t.Fatalf("unexpected registry key: %q", wineFileAssociationsKey) } } + +func TestPowerShellStateRequiresFilesAndMarker(t *testing.T) { + a := testApp(t) + prefix := filepath.Join(a.home, "Games", "star-citizen") + core, profile, wrapper64, wrapper32 := a.powerShellPaths(prefix) + for _, p := range []string{core, profile, wrapper64, wrapper32} { + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, make([]byte, 2048), 0o644); err != nil { + t.Fatal(err) + } + } + if got := a.powerShellState(prefix); got != "repair" { + t.Fatalf("without verification marker got %q", got) + } + if err := a.writePowerShellMarker(prefix, powerShellCoreVersion+"+wrapper-"+powerShellWrapperVersion); err != nil { + t.Fatal(err) + } + if got := a.powerShellState(prefix); got != "ready" { + t.Fatalf("managed compatibility layer got %q", got) + } + if err := os.Remove(wrapper64); err != nil { + t.Fatal(err) + } + if got := a.powerShellState(prefix); got != "repair" { + t.Fatalf("missing wrapper got %q", got) + } +} + +func TestPowerShellPinnedArtifactsUseSHA256(t *testing.T) { + for name, sha := range map[string]string{ + "PowerShell Core": powerShellCoreSHA256, + "PowerShell wrapper": powerShellWrapperSHA256, + } { + if len(sha) != 64 { + t.Fatalf("%s SHA-256 length=%d", name, len(sha)) + } + for _, c := range sha { + if !strings.ContainsRune("0123456789abcdefABCDEF", c) { + t.Fatalf("%s contains non-hex SHA-256 %q", name, sha) + } + } + } + if !strings.Contains(powerShellCoreURL, powerShellCoreVersion) || !strings.Contains(powerShellWrapperURL, powerShellWrapperVersion) { + t.Fatal("pinned PowerShell URLs do not match pinned versions") + } + if powerShellCoreSHA256 != "cd62ad6d8174cc6fb85b335a0058444bc934fe27c39fa97fe342134286d28af9" { + t.Fatal("PowerShell Core checksum drifted from upstream v7.4.19 release") + } + if powerShellWrapperSHA256 != "08f866265e0395f4bc5ddb18f3dff345771d039a62911e506c62084fd2533ec3" { + t.Fatal("PowerShell wrapper checksum drifted from upstream v3.0.5 release") + } +} + +func TestExtractZipSafeRejectsTraversalAndSymlink(t *testing.T) { + makeZip := func(name string, mode os.FileMode) string { + t.Helper() + path := filepath.Join(t.TempDir(), "test.zip") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + h := &zip.FileHeader{Name: name, Method: zip.Store} + h.SetMode(mode) + w, err := zw.CreateHeader(h) + if err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte("payload")) + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return path + } + if err := extractZipSafe(makeZip("../escape", 0o644), t.TempDir()); err == nil { + t.Fatal("ZIP traversal was accepted") + } + if err := extractZipSafe(makeZip("link", os.ModeSymlink|0o777), t.TempDir()); err == nil { + t.Fatal("ZIP symlink was accepted") + } +} + +func TestFormatCommandFailureHandlesMissingExpectedOutput(t *testing.T) { + if got := formatCommandFailure(nil, nil); got == "" { + t.Fatal("nil error and empty output should still yield a diagnostic") + } + if got := formatCommandFailure(nil, []byte("unexpected output")); got != "unexpected output" { + t.Fatalf("unexpected diagnostic %q", got) + } +} + +func TestRequiredReleaseDigestFailsClosed(t *testing.T) { + p := filepath.Join(t.TempDir(), "asset") + if err := os.WriteFile(p, []byte("abc"), 0o600); err != nil { + t.Fatal(err) + } + if err := requireReleaseDigest(p, ""); err == nil { + t.Fatal("missing digest was accepted") + } + if err := requireReleaseDigest(p, "md5:deadbeef"); err == nil { + t.Fatal("non-SHA256 digest was accepted") + } + if err := requireReleaseDigest(p, "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); err != nil { + t.Fatalf("known SHA-256 rejected: %v", err) + } +} diff --git a/backend/cmd/citizen-launcher/support.go b/backend/cmd/citizen-launcher/support.go index 296c372..07528f4 100644 --- a/backend/cmd/citizen-launcher/support.go +++ b/backend/cmd/citizen-launcher/support.go @@ -11,6 +11,7 @@ import ( "os/user" "path/filepath" "regexp" + "sort" "strings" "time" ) @@ -32,7 +33,16 @@ func (a *App) createSupportBundle() (string, error) { } gz := gzip.NewWriter(f) tw := tar.NewWriter(gz) - closeAll := func() { _ = tw.Close(); _ = gz.Close(); _ = f.Close() } + closed := false + closeAll := func() { + if closed { + return + } + closed = true + _ = tw.Close() + _ = gz.Close() + _ = f.Close() + } defer closeAll() add := func(name string, data []byte) error { @@ -49,22 +59,29 @@ func (a *App) createSupportBundle() (string, error) { b = append(b, '\n') _ = add(name, b) } + + gc := a.loadGameConfig() 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")) + st, _ := a.status(false) + addJSON("launcher-status.json", st) + addJSON("self-update.json", func() SelfUpdateStatus { s, _ := a.selfUpdateStatus(false); return s }()) + addJSON("game-config.json", gc) + addJSON("system-readiness.json", a.systemReadiness(gc.Prefix)) + _ = add("README.txt", []byte("Citizen Launcher support bundle\nID: "+id+"\nPersonal paths, email/IP/MAC and common auth/token values are redacted on a best-effort basis. Review before sharing if desired.\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"}, + "uname.txt": {"uname", "-a"}, + "os-release.txt": {"cat", "/etc/os-release"}, + "memory.txt": {"free", "-h"}, + "swap.txt": {"swapon", "--show"}, + "disk.txt": {"df", "-hT"}, + "mount.txt": {"findmnt", "-T", gc.Prefix, "-o", "TARGET,FSTYPE,OPTIONS"}, + "pci.txt": {"lspci", "-nnk"}, + "vulkan.txt": {"vulkaninfo", "--summary"}, + "limits.txt": {"sh", "-c", "printf 'vm.max_map_count='; cat /proc/sys/vm/max_map_count; printf 'nofile='; ulimit -Sn; printf 'nofile-hard='; ulimit -Hn"}, + "maintenance-timer.txt": {"systemctl", "--user", "status", "citizen-launcher-maintenance.timer", "--no-pager"}, + "package-update-timer.txt": {"systemctl", "status", "citizen-launcher-self-update.timer", "--no-pager"}, } { if _, err := exec.LookPath(cmd[0]); err != nil { continue @@ -72,17 +89,63 @@ func (a *App) createSupportBundle() (string, error) { 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"), + "launcher.log": a.logPath, + "rsi-wine.log": filepath.Join(a.stateDir, "rsi-launcher.log"), } { - if b, err := tailFile(path, 700000); err == nil { + if b, err := tailFile(path, 900000); err == nil { _ = add(name, b) } } + + // Product-owned desktop integration is useful for diagnosing launch failures. + for name, path := range map[string]string{ + "desktop-citizen-launcher.txt": a.mainDesktopPath(), + "desktop-star-citizen.txt": a.starDesktopPath(), + "launch-wrapper.txt": filepath.Join(a.dataDir, "bin", "star-citizen-launch"), + } { + if b, err := os.ReadFile(path); err == nil { + _ = add(name, b) + } + } + + // Current RSI launcher / game / EAC logs, capped and sanitized. + for name, path := range a.gameLogCandidates(gc) { + if b, err := tailFile(path, 1200000); err == nil { + _ = add(name, b) + } + } + + closeAll() return target, nil } +func (a *App) gameLogCandidates(gc GameConfig) map[string]string { + out := map[string]string{} + usersRoot := filepath.Join(gc.Prefix, "drive_c", "users") + users, _ := os.ReadDir(usersRoot) + for _, u := range users { + if !u.IsDir() || strings.EqualFold(u.Name(), "Public") { + continue + } + roam := filepath.Join(usersRoot, u.Name(), "AppData", "Roaming") + if _, err := os.Stat(filepath.Join(roam, "rsilauncher", "logs", "log.log")); err == nil { + out["rsi-internal.log"] = filepath.Join(roam, "rsilauncher", "logs", "log.log") + } + matches, _ := filepath.Glob(filepath.Join(roam, "EasyAntiCheat", "*", "*", "anticheatlauncher.log")) + sort.Strings(matches) + if len(matches) > 0 { + out["eac.log"] = matches[len(matches)-1] + } + } + gameLog := filepath.Join(gc.GameDir, "LIVE", "Game.log") + if _, err := os.Stat(gameLog); err == nil { + out["game.log"] = gameLog + } + return out +} + func tailFile(path string, max int64) ([]byte, error) { f, err := os.Open(path) if err != nil { @@ -111,17 +174,19 @@ func sanitizeSupport(in []byte, home string) []byte { 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, "") + 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="}, + {`(?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)\bBearer\s+[A-Za-z0-9._~+/=-]+`, "Bearer "}, + {`\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`, ""}, + {`(?i)(authorization|token|access_token|refresh_token|secret|password|passwd|cookie|session)\s*[:=]\s*[^\s,;]+`, "$1="}, + {`(?i)([?&](?:token|access_token|refresh_token|session|auth|key|secret)=)[^&#\s]+`, "$1"}, } for _, p := range patterns { s = regexp.MustCompile(p.re).ReplaceAllString(s, p.repl) diff --git a/backend/cmd/citizen-launcher/web/app.js b/backend/cmd/citizen-launcher/web/app.js index 949bed1..17e7ab7 100644 --- a/backend/cmd/citizen-launcher/web/app.js +++ b/backend/cmd/citizen-launcher/web/app.js @@ -1,11 +1,14 @@ -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()} +const B=window.CITIZEN_BASE;let current=null,activeJob=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()).trim());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);const value=(v===undefined||v===null||v==='')?'—':String(v);e.textContent=value;e.title=value;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']} -function setHeroHealth(h){$('hero').dataset.health=h||'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||'—';txt('packageUpdates',s.package_auto_update?'automatisch':'manuell',s.package_auto_update?'good':'warn');const ub=$('updateBanner');if(s.restart_required){ub.classList.remove('hidden');$('updateText').textContent='Installiert ist '+(s.installed_version||'eine neuere Version')+', dieses Fenster läuft noch mit '+s.version+'.'}else{ub.classList.add('hidden')}const g=s.game;setHeroHealth(g.health);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. Die Reparatur verändert keine Spieldaten.'}else if(g.health==='install-game'){head='LAUNCHER READY';sub='Der RSI Launcher ist bereit. Melde dich an und installiere Star Citizen.'}$('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}} -function showJob(state,title,message,raw=''){const box=$('job');box.classList.remove('hidden','error','done');if(state==='error')box.classList.add('error');if(state==='done')box.classList.add('done');$('jobTitle').textContent=title;$('jobText').textContent=message||'';const spin=box.querySelector('.spinner');const symbol=$('jobSymbol');spin.classList.toggle('hidden',state!=='running');symbol.classList.toggle('hidden',state==='running');symbol.textContent=state==='done'?'✓':'!';const details=$('jobDetails');if(raw&&raw!==message){details.classList.remove('hidden');$('jobRaw').textContent=raw}else{details.classList.add('hidden');$('jobRaw').textContent=''}} -async function action(name){try{if(name==='restart'){await api('/api/action/restart',{method:'POST'});showJob('done','Launcher wird neu gestartet','Die neue Version wird geöffnet …');return}if(name==='launch'){await api('/api/action/launch',{method:'POST'});showJob('done','RSI Launcher gestartet','Der Launcher wurde gestartet.');return}const j=await api('/api/action/'+name,{method:'POST'});showJob('running','Aktion: '+name,'Wird ausgeführt …');poll(j.id)}catch(e){showJob('error','Aktion konnte nicht gestartet werden',e.message,e.message)}} -async function poll(id){try{const j=await api('/api/job/'+id);if(j.state==='running'){setTimeout(()=>poll(id),800);return}if(j.state==='done')showJob('done','Abgeschlossen',j.message||'Die Aktion wurde erfolgreich abgeschlossen.');else showJob('error','Aktion fehlgeschlagen',j.message||'Die Aktion konnte nicht abgeschlossen werden.',j.error||'');await refresh()}catch(e){showJob('error','Statusfehler','Der Aktionsstatus konnte nicht gelesen werden.',e.message)}} -document.addEventListener('click',e=>{const button=e.target.closest('[data-action],[data-open]');if(!button)return;const a=button.dataset.action;if(a)action(a);const o=button.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 +function primaryFor(h){if(h==='ready')return['STAR CITIZEN STARTEN','launch'];if(h==='install-game')return['RSI LAUNCHER ÖFFNEN','launch'];if(h==='hardware-blocked')return['SYSTEM-DETAILS','support'];if(h==='system-prepare')return['SYSTEM AUTOMATISCH VORBEREITEN','prepare-system'];if(h==='repair'||h==='launcher-repair')return['AUTOMATISCH REPARIEREN','repair'];return['EINRICHTEN & STARTKLAR MACHEN','setup']} +function setStep(name,state){const e=document.querySelector(`[data-step="${name}"]`);if(!e)return;e.dataset.state=state} +function renderSteps(g){setStep('system',g.hardware==='blocked'||g.system?.state==='blocked'?'bad':g.system?.state==='prepare'?'warn':'good');setStep('wine',g.wine_version&&g.powershell_state==='ready'?'good':g.wine_version?'warn':'todo');setStep('launcher',g.launcher_state==='ready'?'good':'todo');setStep('game',g.game_state==='ready'?'good':g.game_state==='partial'?'warn':'todo')} +function setButtonsBusy(busy){document.querySelectorAll('button[data-action]').forEach(b=>{if(b.dataset.action!=='restart')b.disabled=busy});$('primary').disabled=busy} +async function refresh(){try{const s=await api('/api/status');current=s;activeJob=s.active_job||null;$('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||'—';txt('packageUpdates',s.package_auto_update?'automatisch':'manuell',s.package_auto_update?'good':'warn');const ub=$('updateBanner');if(s.restart_required){ub.classList.remove('hidden');$('updateText').textContent='Installiert ist '+(s.installed_version||'eine neuere Version')+', dieses Fenster läuft noch mit '+s.version+'.'}else ub.classList.add('hidden');const g=s.game;$('hero').dataset.health=g.health||'setup';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==='install-game'){head='RSI LAUNCHER READY';sub='Melde dich im RSI Launcher an und installiere Star Citizen am vorgegebenen C:-Pfad.'}else if(g.health==='hardware-blocked'){head='SYSTEM NICHT SPIELBEREIT';sub=g.hardware_reason||g.system?.reason||'Eine Voraussetzung ist noch nicht erfüllt.'}else if(g.health==='system-prepare'){head='SYSTEMVORBEREITUNG';sub='Citizen Launcher kann die benötigten Linux-Limits automatisch setzen.'}else if(g.health==='repair'||g.health==='launcher-repair'){head='REPAIR AVAILABLE';sub='Ein reparierbarer Zustand wurde erkannt. Spieldaten werden nicht gelöscht.'}$('headline').textContent=head;$('subline').textContent=sub;const [label,act]=primaryFor(g.health);$('primary').textContent=label;$('primary').dataset.action=act;renderSteps(g);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('memory',`${g.ram_gib} GiB RAM · ${g.combined_gib} GiB gesamt`,g.combined_gib>=48?'good':'warn');txt('disk',(g.system?.storage_free_gib??g.disk_free_gib)+' GiB',((g.system?.storage_free_gib??g.disk_free_gib)>=150||g.game_state==='ready')?'good':'warn');txt('filesystem',g.system?.filesystem||'unbekannt',g.system?.filesystem_ok===false?'bad':'good');txt('wine',g.wine_version||'wird eingerichtet',g.wine_version?'good':'warn');txt('dxvk',g.dxvk_state==='ready'?(g.dxvk_version||'bereit'):g.dxvk_state==='repair'?((g.dxvk_version||'DXVK')+' · Reparatur nötig'):'wird eingerichtet',g.dxvk_state==='ready'?'good':g.dxvk_state==='repair'?'warn':'warn');txt('powershell',g.powershell_state==='ready'?'bereit':g.prefix_state==='ready'?'Reparatur nötig':'wird eingerichtet',g.powershell_state==='ready'?'good':g.prefix_state==='ready'?'warn':'');txt('rsi',g.launcher_state==='ready'?'bereit':'Setup',g.launcher_state==='ready'?'good':'warn');txt('game',g.game_state==='ready'?'LIVE bereit':g.game_state==='partial'?'teilweise vorhanden':'noch nicht installiert',g.game_state==='ready'?'good':g.game_state==='partial'?'warn':'');txt('prefix',g.prefix||'—');txt('autopilot',g.autopilot?'aktiv':'aus',g.autopilot?'good':'warn');$('systemPill').textContent=g.hardware==='blocked'||g.system?.state==='blocked'?'BLOCKED':g.system?.state==='prepare'?'SETUP':'READY';$('systemPill').className='pill '+(g.hardware==='blocked'||g.system?.state==='blocked'?'bad':g.system?.state==='prepare'?'warn':'good');const stackReady=g.launcher_state==='ready'&&g.dxvk_state==='ready'&&g.powershell_state==='ready';$('stackPill').textContent=stackReady?'READY':'SETUP';$('stackPill').className='pill '+(stackReady?'good':'warn');$('autoPill').textContent=g.autopilot?'AUTO':'OFF';$('autoPill').className='pill '+(g.autopilot?'good':'warn');$('raw').textContent=JSON.stringify(s,null,2);setButtonsBusy(!!activeJob);if(activeJob&&$('job').classList.contains('hidden')){showJob('running',friendlyAction(activeJob.action),'Wird ausgeführt …');poll(activeJob.id)}}catch(e){$('headline').textContent='BACKEND ERROR';$('subline').textContent=e.message}} +function friendlyAction(a){return({setup:'Einrichtung',repair:'Automatische Reparatur',maintain:'Gaming-Stack aktualisieren',doctor:'Wine-Selbsttest',support:'Support-Paket', 'prepare-system':'Systemvorbereitung','choose-prefix':'Installationsordner'})[a]||'Aktion'} +function showJob(state,title,message,raw=''){const box=$('job');box.classList.remove('hidden','error','done');if(state==='error')box.classList.add('error');if(state==='done')box.classList.add('done');$('jobTitle').textContent=title;$('jobText').textContent=message||'';const spin=box.querySelector('.spinner'),symbol=$('jobSymbol');spin.classList.toggle('hidden',state!=='running');symbol.classList.toggle('hidden',state==='running');symbol.textContent=state==='done'?'✓':'!';const details=$('jobDetails');if(raw&&raw!==message){details.classList.remove('hidden');$('jobRaw').textContent=raw}else{details.classList.add('hidden');$('jobRaw').textContent=''}} +async function action(name){try{if(name==='restart'){await api('/api/action/restart',{method:'POST'});showJob('done','Launcher wird neu gestartet','Die neue Version wird geöffnet …');return}if(name==='launch'){const r=await api('/api/action/launch',{method:'POST'});if(r.state==='game-running'){showJob('done','Star Citizen läuft bereits','Es wurde keine zweite Instanz gestartet.')}else if(r.state==='launcher-running'){showJob('done','RSI Launcher läuft bereits','Es wurde keine zweite Instanz gestartet.')}else{showJob('done','RSI Launcher gestartet','Der Launcher wurde gestartet.')}return}const j=await api('/api/action/'+name,{method:'POST'});activeJob=j;setButtonsBusy(true);showJob('running',friendlyAction(name),'Wird ausgeführt …');poll(j.id)}catch(e){setButtonsBusy(false);showJob('error','Aktion konnte nicht gestartet werden',e.message,e.message)}} +async function poll(id){try{const j=await api('/api/job/'+id);if(j.state==='running'){setTimeout(()=>poll(id),900);return}activeJob=null;setButtonsBusy(false);if(j.state==='done'){let m=j.message||'Die Aktion wurde erfolgreich abgeschlossen.';if(j.action==='choose-prefix'&&j.message)m='Installationsort: '+j.message;showJob('done','Abgeschlossen',m)}else showJob('error','Aktion fehlgeschlagen',j.message||'Die Aktion konnte nicht abgeschlossen werden.',j.error||'');await refresh()}catch(e){activeJob=null;setButtonsBusy(false);showJob('error','Statusfehler','Der Aktionsstatus konnte nicht gelesen werden.',e.message)}} +document.addEventListener('click',e=>{const button=e.target.closest('[data-action],[data-open]');if(!button||button.disabled)return;const a=button.dataset.action;if(a)action(a);const o=button.dataset.open;if(o)api('/api/open/'+o,{method:'POST'}).catch(err=>showJob('error','Öffnen fehlgeschlagen',err.message,err.message))});$('toggle').onclick=()=>$('advanced').classList.toggle('hidden');refresh();setInterval(refresh,10000); diff --git a/backend/cmd/citizen-launcher/web/index.html b/backend/cmd/citizen-launcher/web/index.html index aab4130..cc91b3c 100644 --- a/backend/cmd/citizen-launcher/web/index.html +++ b/backend/cmd/citizen-launcher/web/index.html @@ -2,16 +2,16 @@ Citizen Launcher
-
✦
LINUX FLIGHT SYSTEM

Citizen Launcher

Linux wird erkannt …

+
✦
CITIZEN FLIGHT SYSTEM

Citizen Launcher

Linux wird erkannt …

-
SYSTEM STATE

SYSTEM CHECK

Hardware und Gaming-Stack werden geprüft.

+
FLIGHT STATUS

SYSTEM CHECK

Hardware und Gaming-Stack werden geprüft.

SYSTEMWINERSIGAME
-

Hardware

GPU—
Vulkan—
RAM—
-

Gaming Stack

Wine—
DXVK—
Launcher—
-

Autopilot

Gaming-Stack—
Launcher-Updates—
Distribution—
Session—
+

System

CHECK
GPU—
Vulkan—
RAM + Swap—
Speicher frei—
Dateisystem—
+

Gaming Stack

CHECK
Wine—
DXVK—
RSI-Kompatibilität—
RSI Launcher—
Spieldateien—
Installationsort—
+

Autopilot

CHECK
Gaming-Stack—
Launcher-Updates—
Distribution—
Session—
-
+
-
-
Citizen Launcher verwaltet den Gaming-Stack im Benutzerkonto. System- und GPU-Treiber bleiben bei deiner Distribution.
-
\ No newline at end of file +
+
Citizen Launcher 1.0 verwaltet Wine, DXVK, RSI-Kompatibilität, Launcher und Updates. Systemtreiber bleiben bei deiner Distribution.
+ diff --git a/backend/cmd/citizen-launcher/web/style.css b/backend/cmd/citizen-launcher/web/style.css index a1cc4fb..2d6fad7 100644 --- a/backend/cmd/citizen-launcher/web/style.css +++ b/backend/cmd/citizen-launcher/web/style.css @@ -1 +1 @@ -:root{color-scheme:dark;--bg:#060b11;--panel:#0a1721;--panel2:#0d202d;--line:#183d50;--line2:#2b6f8d;--cyan:#54d8ff;--green:#70e7ad;--amber:#ffd06b;--red:#ff8398;--text:#edf8fc;--muted:#8da7b5;--shadow:#0008}*{box-sizing:border-box}html{background:var(--bg)}body{margin:0;min-height:100vh;color:var(--text);font:15px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at 68% -15%,#14354d 0,transparent 40%),linear-gradient(145deg,#050a0f,#09151f 60%,#06111a)}.scan{position:fixed;inset:0;pointer-events:none;opacity:.07;background:repeating-linear-gradient(0deg,transparent 0 4px,#66dbff12 5px)}main{width:min(1280px,calc(100% - 40px));margin:26px auto 48px;position:relative}header{display:flex;align-items:center;gap:15px;padding:0 3px 20px;min-width:0}.mark{width:52px;height:52px;flex:0 0 52px;display:grid;place-items:center;border:1px solid var(--cyan);border-radius:14px;color:var(--cyan);font-size:24px;background:#0c2030aa;box-shadow:0 0 25px #4bd5ff1d inset}.brand{min-width:0}.eyebrow{font-size:10px;letter-spacing:.2em;color:var(--cyan);font-weight:850}h1,h2,h3,p{margin:0}h1{font-size:24px;letter-spacing:.015em}header p{color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.version{margin-left:auto;align-self:flex-start;padding-top:13px;color:var(--muted);font:13px ui-monospace,SFMono-Regular,Consolas,monospace}.hero,.card,.job,.details{border:1px solid var(--line);background:linear-gradient(155deg,#0c1b26ed,#08131bed);box-shadow:0 20px 60px var(--shadow)}.hero{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:26px;padding:26px;border-color:var(--line2);border-radius:18px;min-width:0}.hero-copy{min-width:0}.hero h2{font-size:clamp(28px,3vw,38px);line-height:1.05;letter-spacing:.045em;margin:7px 0 9px}.hero p{color:var(--muted);max-width:740px}.primary{min-width:250px;padding:16px 24px!important;font-weight:900;letter-spacing:.075em;border-color:var(--cyan)!important;color:#06131b!important;background:linear-gradient(135deg,#7de5ff,#45c8f7)!important;box-shadow:0 8px 30px #37c7f522}.dashboard{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,.78fr) minmax(0,.9fr);gap:14px;margin-top:14px;align-items:stretch}.card{padding:19px;border-radius:17px;min-width:0;overflow:hidden}.card h3{color:var(--cyan);font-size:12px;letter-spacing:.14em;text-transform:uppercase;margin-bottom:10px}.metric{display:grid;grid-template-columns:minmax(88px,.48fr) minmax(0,1fr);align-items:start;gap:12px;padding:9px 0;border-bottom:1px solid #143445;min-width:0}.metric:last-child{border:0}.metric span{color:var(--muted)}.metric b{min-width:0;text-align:right;overflow-wrap:anywhere;word-break:break-word}.actions{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:14px 0}.actions button{display:flex;flex-direction:column;align-items:flex-start;text-align:left;gap:2px;min-width:0}.actions strong{font-weight:700}.actions small{color:var(--muted);font-size:11px;white-space:normal}button{border:1px solid #294e63;background:#0c1b25;color:var(--text);padding:12px 15px;border-radius:10px;cursor:pointer;font:inherit;transition:border-color .15s,background .15s,transform .15s}button:hover{border-color:var(--cyan);background:#102837}button:active{transform:translateY(1px)}.job{display:grid;grid-template-columns:auto minmax(0,1fr);gap:13px;align-items:start;padding:16px 17px;margin:14px 0;border-radius:15px}.job.error{border-color:#7e3142;background:linear-gradient(155deg,#23131a,#0b141b)}.job.done{border-color:#285e4b}.job-icon{width:26px;height:26px;display:grid;place-items:center}.job-body{min-width:0}.job p{color:var(--muted);margin-top:4px;white-space:pre-wrap;overflow-wrap:anywhere}.job-symbol{font-size:18px;font-weight:900}.job.error .job-symbol{color:var(--red)}.job.done .job-symbol{color:var(--green)}.spinner{width:21px;height:21px;border:2px solid #285165;border-top-color:var(--cyan);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.job-details{margin-top:10px}.job-details summary{cursor:pointer;color:#b8d0db;font-size:12px}.job-details pre{margin-top:8px}.details{padding:10px 13px;border-radius:15px}.details>button{width:100%;background:transparent;border:0;color:var(--muted)}pre{white-space:pre-wrap;overflow-wrap:anywhere;color:#a9c7d4;background:#050f16;padding:13px;border:1px solid #112e3d;border-radius:10px;max-height:330px;overflow:auto;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}.small-actions{display:flex;flex-wrap:wrap;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:11px;padding:22px 8px}@media(max-width:1080px){main{width:min(980px,calc(100% - 32px))}.dashboard{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard .hardware{grid-column:1/-1}.actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){main{width:calc(100% - 22px);margin-top:14px}.hero{grid-template-columns:1fr;padding:20px}.primary{width:100%;min-width:0}.dashboard{grid-template-columns:1fr}.dashboard .hardware{grid-column:auto}.actions{grid-template-columns:1fr}header{padding-bottom:14px}.version{font-size:11px}.metric{grid-template-columns:minmax(80px,.42fr) minmax(0,1fr)}}.update-banner{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:0 0 14px;padding:14px 16px;border:1px solid #2a775b;border-radius:14px;background:linear-gradient(135deg,#0d2a20,#0a1820);box-shadow:0 12px 35px #0006}.update-banner b{color:var(--green);font-size:14px}.update-banner p{color:var(--muted);margin-top:2px}.update-banner button{white-space:nowrap;border-color:var(--green)}@media(max-width:720px){.update-banner{align-items:stretch;flex-direction:column}.update-banner button{width:100%}} +:root{color-scheme:dark;--bg:#050a0f;--panel:#09151f;--panel2:#0d202d;--line:#16394a;--line2:#2d7897;--cyan:#5bdcff;--green:#74e7ad;--amber:#ffd16f;--red:#ff8298;--text:#eff9fc;--muted:#8da8b6;--shadow:#0009}*{box-sizing:border-box}html{background:var(--bg);overflow-x:hidden}body{margin:0;min-height:100vh;color:var(--text);font:15px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at 70% -10%,#133952 0,transparent 38%),linear-gradient(145deg,#05090e,#08141e 58%,#06121a);overflow-x:hidden}.scan{position:fixed;inset:0;pointer-events:none;opacity:.045;background:repeating-linear-gradient(0deg,transparent 0 5px,#66dbff14 6px)}main{width:min(1180px,calc(100% - 36px));margin:24px auto 46px;position:relative;min-width:0}header{display:flex;align-items:center;gap:14px;padding:0 3px 18px;min-width:0}.mark{width:50px;height:50px;flex:0 0 50px;display:grid;place-items:center;border:1px solid var(--cyan);border-radius:15px;color:var(--cyan);font-size:23px;background:#0c2030bb;box-shadow:0 0 28px #4bd5ff20 inset}.brand{min-width:0}.eyebrow{font-size:10px;letter-spacing:.2em;color:var(--cyan);font-weight:850}h1,h2,h3,p{margin:0}h1{font-size:23px;letter-spacing:.015em}header p{color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.version{margin-left:auto;align-self:flex-start;padding-top:13px;color:var(--muted);font:13px ui-monospace,SFMono-Regular,Consolas,monospace}.hero,.card,.job,.details{border:1px solid var(--line);background:linear-gradient(155deg,#0b1b26f2,#07121aed);box-shadow:0 20px 60px var(--shadow)}.hero{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,auto);align-items:center;gap:24px;padding:25px;border-color:var(--line2);border-radius:19px;min-width:0}.hero-copy{min-width:0}.hero h2{font-size:clamp(27px,3.4vw,39px);line-height:1.04;letter-spacing:.045em;margin:6px 0 9px}.hero p{color:var(--muted);max-width:760px}.hero[data-health="ready"]{border-color:#28725a}.hero[data-health="hardware-blocked"]{border-color:#7e3142}.hero[data-health="system-prepare"],.hero[data-health="repair"],.hero[data-health="launcher-repair"]{border-color:#7b6530}.primary{min-width:240px;padding:16px 22px!important;font-weight:900;letter-spacing:.07em;border-color:var(--cyan)!important;color:#06131b!important;background:linear-gradient(135deg,#82e7ff,#47c9f7)!important;box-shadow:0 8px 30px #37c7f523}.steps{display:flex;align-items:center;gap:7px;margin-top:18px;max-width:610px}.steps span{font-size:9px;font-weight:850;letter-spacing:.12em;color:#607c89;padding:5px 7px;border-radius:999px;border:1px solid #173747;background:#08141c}.steps span[data-state="good"]{color:var(--green);border-color:#285e4b}.steps span[data-state="warn"]{color:var(--amber);border-color:#6b582e}.steps span[data-state="bad"]{color:var(--red);border-color:#6c3140}.steps i{height:1px;background:#193b4b;flex:1;min-width:8px}.dashboard{display:grid;grid-template-columns:minmax(0,1.2fr) repeat(2,minmax(0,1fr));gap:13px;margin-top:13px;align-items:stretch}.card{padding:18px;border-radius:17px;min-width:0;overflow:hidden}.card-title{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:7px}.card h3{color:var(--cyan);font-size:12px;letter-spacing:.14em;text-transform:uppercase}.pill{font-size:9px;letter-spacing:.11em;border:1px solid #285165;padding:3px 7px;border-radius:999px;color:var(--muted)}.pill.good{border-color:#285e4b}.pill.warn{border-color:#6b582e}.pill.bad{border-color:#6c3140}.metric{display:grid;grid-template-columns:minmax(92px,.42fr) minmax(0,1fr);align-items:start;gap:12px;padding:8px 0;border-bottom:1px solid #123242;min-width:0}.metric:last-child{border:0}.metric span{color:var(--muted)}.metric b{min-width:0;text-align:right;overflow-wrap:anywhere;word-break:break-word;font-weight:650}.actions{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:13px 0}.actions button{display:flex;flex-direction:column;align-items:flex-start;text-align:left;gap:2px;min-width:0}.actions strong{font-weight:750}.actions small{color:var(--muted);font-size:11px;white-space:normal}button{border:1px solid #294e63;background:#0b1b25;color:var(--text);padding:12px 15px;border-radius:11px;cursor:pointer;font:inherit;transition:border-color .15s,background .15s,transform .15s,opacity .15s}button:hover:not(:disabled){border-color:var(--cyan);background:#102837}button:active:not(:disabled){transform:translateY(1px)}button:disabled{opacity:.42;cursor:not-allowed}.job{display:grid;grid-template-columns:auto minmax(0,1fr);gap:13px;align-items:start;padding:16px 17px;margin:13px 0;border-radius:15px}.job.error{border-color:#7e3142;background:linear-gradient(155deg,#23131a,#0b141b)}.job.done{border-color:#285e4b}.job-icon{width:26px;height:26px;display:grid;place-items:center}.job-body{min-width:0}.job p{color:var(--muted);margin-top:4px;white-space:pre-wrap;overflow-wrap:anywhere}.job-symbol{font-size:18px;font-weight:900}.job.error .job-symbol{color:var(--red)}.job.done .job-symbol{color:var(--green)}.spinner{width:21px;height:21px;border:2px solid #285165;border-top-color:var(--cyan);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.job-details{margin-top:10px}.job-details summary,.details summary{cursor:pointer;color:#b8d0db;font-size:12px}.job-details pre{margin-top:8px}.details{padding:10px 13px;border-radius:15px}.details>button{width:100%;background:transparent;border:0;color:var(--muted)}pre{white-space:pre-wrap;overflow-wrap:anywhere;color:#a9c7d4;background:#050f16;padding:13px;border:1px solid #112e3d;border-radius:10px;max-height:350px;overflow:auto;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}.advanced-actions{display:flex;flex-wrap:wrap;gap:8px;padding:8px 0 12px}.advanced-actions button{font-size:12px}.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:11px;padding:21px 8px}.update-banner{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:0 0 13px;padding:14px 16px;border:1px solid #2a775b;border-radius:14px;background:linear-gradient(135deg,#0d2a20,#0a1820);box-shadow:0 12px 35px #0006}.update-banner b{color:var(--green);font-size:14px}.update-banner p{color:var(--muted);margin-top:2px}.update-banner button{white-space:nowrap;border-color:var(--green)}@media(max-width:1000px){main{width:min(900px,calc(100% - 28px))}.dashboard{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard .hardware{grid-column:1/-1}.actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:680px){main{width:calc(100% - 20px);margin-top:12px}.hero{grid-template-columns:1fr;padding:19px}.primary{width:100%;min-width:0}.dashboard{grid-template-columns:1fr}.dashboard .hardware{grid-column:auto}.actions{grid-template-columns:1fr}header{padding-bottom:13px}.version{font-size:11px}.metric{grid-template-columns:minmax(92px,.44fr) minmax(0,1fr)}.steps span{font-size:8px;padding:4px 5px}.update-banner{align-items:stretch;flex-direction:column}.update-banner button{width:100%}} diff --git a/dist/citizen-launcher-1.0.0-linux-amd64.tar.gz b/dist/citizen-launcher-1.0.0-linux-amd64.tar.gz new file mode 100644 index 0000000..af7a5b9 Binary files /dev/null and b/dist/citizen-launcher-1.0.0-linux-amd64.tar.gz differ diff --git a/dist/citizen-launcher_1.0.0_amd64.deb b/dist/citizen-launcher_1.0.0_amd64.deb new file mode 100644 index 0000000..891defc Binary files /dev/null and b/dist/citizen-launcher_1.0.0_amd64.deb differ diff --git a/install.sh b/install.sh index ede0c05..f0ecfdc 100644 --- a/install.sh +++ b/install.sh @@ -3,24 +3,40 @@ 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" +ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps" [[ -x "$BIN_SRC" ]] || { echo "Backend fehlt. Zuerst ./build.sh ausführen." >&2; exit 1; } -mkdir -p "$BIN_DIR" "$LIB_DIR" "$APP_DIR" +mkdir -p "$BIN_DIR" "$APP_DIR" "$ICON_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" < "$APP_DIR/io.github.citizenlauncher.CitizenLauncher.desktop" </dev/null 2>&1; then update-desktop-database "$APP_DIR" >/dev/null 2>&1 || true; fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -f "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" >/dev/null 2>&1 || true; fi + echo "Citizen Launcher installiert. Starte ihn über das App-Menü oder: citizen-launcher gui" +if ((${#warnings[@]})); then + printf '\nHinweise:\n' + printf ' - %s\n' "${warnings[@]}" +fi diff --git a/integrations/omarchy/Panel.qml b/integrations/omarchy/Panel.qml index 2847b7c..d675556 100644 --- a/integrations/omarchy/Panel.qml +++ b/integrations/omarchy/Panel.qml @@ -12,7 +12,7 @@ Panel { property var anchorItem: null property var hostWidget: null - property string pluginVersion: "0.9.1" + property string pluginVersion: "1.0.0" property string health: "checking" property string depsState: "checking" property string depsMissing: "" @@ -106,7 +106,7 @@ Panel { values[lines[i].slice(0, p)] = lines[i].slice(p + 1) } - pluginVersion = values.plugin_version || "0.9.1" + pluginVersion = values.plugin_version || "1.0.0" health = values.health || "setup" depsState = values.deps || "missing" depsMissing = values.deps_missing || "" diff --git a/integrations/omarchy/citizenctl b/integrations/omarchy/citizenctl index 6c6332e..e2d803e 100644 --- a/integrations/omarchy/citizenctl +++ b/integrations/omarchy/citizenctl @@ -2,7 +2,7 @@ set -u PLUGIN_ID="local.omarchy-citizen" -PLUGIN_VERSION="0.9.1" +PLUGIN_VERSION="1.0.0" XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" diff --git a/integrations/omarchy/manifest.json b/integrations/omarchy/manifest.json index 0201c2b..d4720e3 100644 --- a/integrations/omarchy/manifest.json +++ b/integrations/omarchy/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "local.omarchy-citizen", "name": "Citizen Launcher · Omarchy", - "version": "0.9.1", + "version": "1.0.0", "author": "Community prototype", "license": "MIT", "description": "Optional Omarchy bar integration for the distro-neutral Citizen Launcher.", diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 4b6f11c..4f44a40 100644 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -1,38 +1,64 @@ #!/usr/bin/env bash set -euo pipefail ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")"; ARCH="amd64"; WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")" +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" "$PKG/usr/lib/systemd/system" "$PKG/etc/citizen-launcher" -install -m755 "$ROOT/backend/bin/citizen-launcher" "$PKG/usr/bin/citizen-launcher" +mkdir -p \ + "$PKG/DEBIAN" \ + "$PKG/usr/bin" \ + "$PKG/usr/share/applications" \ + "$PKG/usr/share/icons/hicolor/scalable/apps" \ + "$PKG/usr/share/metainfo" \ + "$PKG/usr/lib/systemd/system" \ + "$PKG/usr/lib/sysctl.d" \ + "$PKG/etc/security/limits.d" \ + "$PKG/etc/xdg/autostart" \ + "$PKG/etc/citizen-launcher" +install -m755 "$ROOT/backend/bin/citizen-launcher" "$PKG/usr/bin/citizen-launcher" install -m644 "$ROOT/packaging/systemd/citizen-launcher-self-update.service" "$PKG/usr/lib/systemd/system/citizen-launcher-self-update.service" install -m644 "$ROOT/packaging/systemd/citizen-launcher-self-update.timer" "$PKG/usr/lib/systemd/system/citizen-launcher-self-update.timer" +install -m644 "$ROOT/packaging/icons/citizen-launcher.svg" "$PKG/usr/share/icons/hicolor/scalable/apps/citizen-launcher.svg" +install -m644 "$ROOT/packaging/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml" "$PKG/usr/share/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml" +install -m644 "$ROOT/packaging/citizen-launcher-migrate.desktop" "$PKG/etc/xdg/autostart/citizen-launcher-migrate.desktop" printf '%s\n' "${CITIZEN_LAUNCHER_RELEASE_REPO:-sendnwv/omarchy-sc}" > "$PKG/etc/citizen-launcher/release-repo" +printf '%s\n' '# Citizen Launcher / Star Citizen' 'vm.max_map_count = 16777216' > "$PKG/usr/lib/sysctl.d/90-citizen-launcher.conf" +printf '%s\n' '# Citizen Launcher / Star Citizen' '* soft nofile 524288' '* hard nofile 524288' > "$PKG/etc/security/limits.d/90-citizen-launcher.conf" install -m755 "$ROOT/packaging/postinst" "$PKG/DEBIAN/postinst" install -m755 "$ROOT/packaging/prerm" "$PKG/DEBIAN/prerm" install -m755 "$ROOT/packaging/postrm" "$PKG/DEBIAN/postrm" -cat > "$PKG/DEBIAN/control" < "$PKG/DEBIAN/control" < "$PKG/usr/share/applications/io.github.citizenlauncher.CitizenLauncher.desktop" < "$PKG/usr/share/applications/io.github.citizenlauncher.CitizenLauncher.desktop" <<'EOF2' [Desktop Entry] Name=Citizen Launcher Comment=Star Citizen for Linux -Exec=citizen-launcher gui +Exec=/usr/bin/citizen-launcher gui +TryExec=/usr/bin/citizen-launcher +Icon=citizen-launcher Terminal=false Type=Application Categories=Game; StartupNotify=true -EOF +StartupWMClass=CitizenLauncher +Keywords=Star Citizen;RSI;Wine;Gaming; +EOF2 + mkdir -p "$ROOT/dist" dpkg-deb --build --root-owner-group "$PKG" "$ROOT/dist/citizen-launcher_${VERSION}_${ARCH}.deb" diff --git a/packaging/citizen-launcher-migrate.desktop b/packaging/citizen-launcher-migrate.desktop new file mode 100644 index 0000000..eadbfff --- /dev/null +++ b/packaging/citizen-launcher-migrate.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=Citizen Launcher Migration +Exec=/usr/bin/citizen-launcher migrate-user +NoDisplay=true +Terminal=false +X-GNOME-Autostart-enabled=true +X-KDE-autostart-after=panel diff --git a/packaging/citizen-launcher.spec b/packaging/citizen-launcher.spec index 6cb24d0..289f2be 100644 --- a/packaging/citizen-launcher.spec +++ b/packaging/citizen-launcher.spec @@ -1,5 +1,5 @@ Name: citizen-launcher -Version: 0.9.2 +Version: 1.0.0 Release: 1%{?dist} Summary: Star Citizen launcher and self-maintaining Wine stack for Linux License: MIT diff --git a/packaging/icons/citizen-launcher.svg b/packaging/icons/citizen-launcher.svg new file mode 100644 index 0000000..19b8284 --- /dev/null +++ b/packaging/icons/citizen-launcher.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packaging/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml b/packaging/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml new file mode 100644 index 0000000..8235af1 --- /dev/null +++ b/packaging/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml @@ -0,0 +1,18 @@ + + + io.github.citizenlauncher.CitizenLauncher + Citizen Launcher + Set up, launch, repair and maintain Star Citizen on Linux + CC0-1.0 + MIT + Citizen Launcher Project + +

Citizen Launcher manages a locally compatible Wine runner, DXVK, RSI Launcher compatibility, automatic maintenance and support diagnostics for Star Citizen on Linux.

+
+ io.github.citizenlauncher.CitizenLauncher.desktop + https://github.com/sendnwv/omarchy-sc + Game + citizen-launcher + + +
diff --git a/packaging/postinst b/packaging/postinst index c93dfc7..86b0233 100644 --- a/packaging/postinst +++ b/packaging/postinst @@ -1,7 +1,16 @@ #!/bin/sh set -e +if command -v sysctl >/dev/null 2>&1; then + sysctl -q -w vm.max_map_count=16777216 >/dev/null 2>&1 || true +fi if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload >/dev/null 2>&1 || true systemctl enable --now citizen-launcher-self-update.timer >/dev/null 2>&1 || true fi +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database /usr/share/applications >/dev/null 2>&1 || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q /usr/share/icons/hicolor >/dev/null 2>&1 || true +fi exit 0 diff --git a/tests/full-verify.sh b/tests/full-verify.sh new file mode 100644 index 0000000..361446e --- /dev/null +++ b/tests/full-verify.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +"$ROOT/tests/verify.sh" +(cd "$ROOT/backend" && go test -race ./...) +"$ROOT/backend/integration-test.sh" +"$ROOT/tests/package-verify.sh" +echo 'Citizen Launcher full verification: OK' diff --git a/tests/package-verify.sh b/tests/package-verify.sh new file mode 100644 index 0000000..07a51a2 --- /dev/null +++ b/tests/package-verify.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")" +rm -rf "$ROOT/dist" +mkdir -p "$ROOT/dist" +"$ROOT/packaging/build-deb.sh" +"$ROOT/packaging/build-tarball.sh" +DEB="$ROOT/dist/citizen-launcher_${VERSION}_amd64.deb" +TAR="$ROOT/dist/citizen-launcher-${VERSION}-linux-amd64.tar.gz" +[[ -s "$DEB" && -s "$TAR" ]] +[[ "$(dpkg-deb -f "$DEB" Package)" == "citizen-launcher" ]] +[[ "$(dpkg-deb -f "$DEB" Version)" == "$VERSION" ]] +[[ "$(dpkg-deb -f "$DEB" Architecture)" == "amd64" ]] +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +dpkg-deb -x "$DEB" "$TMP/root" +[[ "$("$TMP/root/usr/bin/citizen-launcher" --version)" == "$VERSION" ]] +for f in \ + usr/lib/systemd/system/citizen-launcher-self-update.service \ + usr/lib/systemd/system/citizen-launcher-self-update.timer \ + usr/lib/sysctl.d/90-citizen-launcher.conf \ + etc/security/limits.d/90-citizen-launcher.conf \ + etc/xdg/autostart/citizen-launcher-migrate.desktop \ + usr/share/applications/io.github.citizenlauncher.CitizenLauncher.desktop \ + usr/share/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml; do + [[ -f "$TMP/root/$f" ]] || { echo "Missing DEB payload: $f" >&2; exit 1; } +done +mkdir -p "$TMP/tar" +tar -xzf "$TAR" -C "$TMP/tar" +[[ "$("$TMP/tar/citizen-launcher" --version)" == "$VERSION" ]] +grep -q '^Exec=/usr/bin/citizen-launcher gui$' "$TMP/root/usr/share/applications/io.github.citizenlauncher.CitizenLauncher.desktop" +grep -q '^ExecStart=/usr/bin/citizen-launcher self-update apply --system --quiet$' "$TMP/root/usr/lib/systemd/system/citizen-launcher-self-update.service" +echo 'Citizen Launcher package verification: OK' diff --git a/tests/verify.sh b/tests/verify.sh index e9c271b..a491de2 100644 --- a/tests/verify.sh +++ b/tests/verify.sh @@ -1,10 +1,17 @@ #!/usr/bin/env bash set -euo pipefail ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -for f in build.sh install.sh install-omarchy.sh uninstall.sh packaging/build-deb.sh packaging/build-tarball.sh integrations/omarchy/citizenctl; do bash -n "$ROOT/$f"; done -(cd "$ROOT/backend" && gofmt -w cmd/citizen-launcher/*.go && go test ./... && go vet ./... && ./build.sh) +for f in build.sh install.sh install-omarchy.sh uninstall.sh packaging/build-deb.sh packaging/build-tarball.sh backend/integration-test.sh integrations/omarchy/citizenctl; do + bash -n "$ROOT/$f" +done +UNFORMATTED="$(cd "$ROOT/backend" && gofmt -l cmd/citizen-launcher/*.go)" +if [[ -n "$UNFORMATTED" ]]; then + echo "Go source is not gofmt-clean:" >&2 + echo "$UNFORMATTED" >&2 + exit 1 +fi +(cd "$ROOT/backend" && go test ./... && go vet ./... && ./build.sh) "$ROOT/backend/bin/citizen-launcher" --version | grep -qx "$(tr -d '[:space:]' < "$ROOT/VERSION")" "$ROOT/backend/bin/citizen-launcher" platform --json | grep -q '"id"' -# Ensure GUI assets are embedded by looking for a distinctive string in binary. grep -a -q 'FLIGHT READY' "$ROOT/backend/bin/citizen-launcher" echo 'Citizen Launcher verification: OK' diff --git a/uninstall.sh b/uninstall.sh index d769d94..b67fe8f 100644 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,8 +1,19 @@ #!/usr/bin/env bash set -euo pipefail BIN="$HOME/.local/bin/citizen-launcher" -[[ -x "$BIN" ]] && "$BIN" uninstall-service >/dev/null 2>&1 || true +DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" +CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +if [[ -x "$BIN" ]]; then + "$BIN" autopilot disable >/dev/null 2>&1 || true +fi +systemctl --user disable --now citizen-launcher-maintenance.timer >/dev/null 2>&1 || true +rm -f "$CONFIG_HOME/systemd/user/citizen-launcher-maintenance.service" "$CONFIG_HOME/systemd/user/citizen-launcher-maintenance.timer" +systemctl --user daemon-reload >/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" +rm -f "$DATA_HOME/applications/io.github.citizenlauncher.CitizenLauncher.desktop" +rm -f "$DATA_HOME/applications/citizen-launcher-star-citizen.desktop" +rm -f "$DATA_HOME/icons/hicolor/scalable/apps/citizen-launcher.svg" +rm -f "$DATA_HOME/citizen-launcher/bin/star-citizen-launch" +if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "$DATA_HOME/applications" >/dev/null 2>&1 || true; fi 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." +echo "Citizen Launcher entfernt. Star-Citizen-Prefix, Spieldateien, Konfiguration und Support-Logs bleiben erhalten."