This commit is contained in:
2026-08-31 22:20:17 +02:00
parent eefada36e9
commit eeac39e7b1
40 changed files with 3203 additions and 449 deletions
+44 -11
View File
@@ -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.
+10 -8
View File
@@ -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.
+54 -72
View File
@@ -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.
+38 -21
View File
@@ -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_<version>_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.
+55
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
0.9.2
1.0.0
Binary file not shown.
@@ -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
}
@@ -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)
}
}
}
+318 -67
View File
@@ -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)
}
+231 -63
View File
@@ -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 &copy
}
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 &copy
}
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
}
@@ -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())
}
}
+191
View File
@@ -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)
}
}
+293
View File
@@ -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
}
+49 -23
View File
@@ -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
+134
View File
@@ -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
}
+46 -10
View File
@@ -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)
@@ -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")
}
}
+642 -103
View File
@@ -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
+116 -2
View File
@@ -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)
}
}
+87 -22
View File
@@ -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, "<user>")
s = regexp.MustCompile(`(?i)\b`+regexp.QuoteMeta(u.Username)+`\b`).ReplaceAllString(s, "<user>")
}
if h, _ := os.Hostname(); h != "" {
s = strings.ReplaceAll(s, h, "<host>")
}
patterns := []struct{ re, repl string }{
{`(?i)\\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}\\b`, "<email>"},
{`\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b`, "<ip>"},
{`(?i)\\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\\b`, "<mac>"},
{`(?i)Bearer\\s+[A-Za-z0-9._~+/=-]+`, "Bearer <redacted>"},
{`(?i)(token|secret|password|passwd|cookie|session)\\s*[:=]\\s*[^\\s,;]+`, "$1=<redacted>"},
{`(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b`, "<email>"},
{`\b(?:\d{1,3}\.){3}\d{1,3}\b`, "<ip>"},
{`(?i)\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b`, "<mac>"},
{`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+`, "Bearer <redacted>"},
{`\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`, "<jwt>"},
{`(?i)(authorization|token|access_token|refresh_token|secret|password|passwd|cookie|session)\s*[:=]\s*[^\s,;]+`, "$1=<redacted>"},
{`(?i)([?&](?:token|access_token|refresh_token|session|auth|key|secret)=)[^&#\s]+`, "$1<redacted>"},
}
for _, p := range patterns {
s = regexp.MustCompile(p.re).ReplaceAllString(s, p.repl)
+12 -9
View File
@@ -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);
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);
+9 -9
View File
@@ -2,16 +2,16 @@
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Citizen Launcher</title><link rel="stylesheet" href="__BASE__/assets/style.css"></head>
<body><div class="scan"></div><main>
<header><div class="mark">✦</div><div class="brand"><div class="eyebrow">LINUX FLIGHT SYSTEM</div><h1>Citizen Launcher</h1><p id="platform">Linux wird erkannt …</p></div><div class="version" id="version"></div></header>
<header><div class="mark">✦</div><div class="brand"><div class="eyebrow">CITIZEN FLIGHT SYSTEM</div><h1>Citizen Launcher</h1><p id="platform">Linux wird erkannt …</p></div><div class="version" id="version"></div></header>
<section class="update-banner hidden" id="updateBanner"><div><b>Neue Version installiert</b><p id="updateText">Citizen Launcher wurde im Hintergrund aktualisiert.</p></div><button data-action="restart">LAUNCHER NEU STARTEN</button></section>
<section class="hero" id="hero"><div class="hero-copy"><div class="eyebrow">SYSTEM STATE</div><h2 id="headline">SYSTEM CHECK</h2><p id="subline">Hardware und Gaming-Stack werden geprüft.</p></div><button class="primary" id="primary">PRÜFEN …</button></section>
<section class="hero" id="hero"><div class="hero-copy"><div class="eyebrow">FLIGHT STATUS</div><h2 id="headline">SYSTEM CHECK</h2><p id="subline">Hardware und Gaming-Stack werden geprüft.</p><div class="steps" id="steps"><span data-step="system">SYSTEM</span><i></i><span data-step="wine">WINE</span><i></i><span data-step="launcher">RSI</span><i></i><span data-step="game">GAME</span></div></div><button class="primary" id="primary">PRÜFEN …</button></section>
<section class="dashboard">
<article class="card hardware"><h3>Hardware</h3><div class="metric"><span>GPU</span><b id="gpu">—</b></div><div class="metric"><span>Vulkan</span><b id="vulkan">—</b></div><div class="metric"><span>RAM</span><b id="ram">—</b></div></article>
<article class="card"><h3>Gaming Stack</h3><div class="metric"><span>Wine</span><b id="wine">—</b></div><div class="metric"><span>DXVK</span><b id="dxvk">—</b></div><div class="metric"><span>Launcher</span><b id="rsi">—</b></div></article>
<article class="card"><h3>Autopilot</h3><div class="metric"><span>Gaming-Stack</span><b id="autopilot">—</b></div><div class="metric"><span>Launcher-Updates</span><b id="packageUpdates">—</b></div><div class="metric"><span>Distribution</span><b id="distro">—</b></div><div class="metric"><span>Session</span><b id="session">—</b></div></article>
<article class="card hardware"><div class="card-title"><h3>System</h3><span class="pill" id="systemPill">CHECK</span></div><div class="metric"><span>GPU</span><b id="gpu">—</b></div><div class="metric"><span>Vulkan</span><b id="vulkan">—</b></div><div class="metric"><span>RAM + Swap</span><b id="memory">—</b></div><div class="metric"><span>Speicher frei</span><b id="disk">—</b></div><div class="metric"><span>Dateisystem</span><b id="filesystem">—</b></div></article>
<article class="card"><div class="card-title"><h3>Gaming Stack</h3><span class="pill" id="stackPill">CHECK</span></div><div class="metric"><span>Wine</span><b id="wine">—</b></div><div class="metric"><span>DXVK</span><b id="dxvk">—</b></div><div class="metric"><span>RSI-Kompatibilität</span><b id="powershell">—</b></div><div class="metric"><span>RSI Launcher</span><b id="rsi">—</b></div><div class="metric"><span>Spieldateien</span><b id="game">—</b></div><div class="metric"><span>Installationsort</span><b id="prefix">—</b></div></article>
<article class="card"><div class="card-title"><h3>Autopilot</h3><span class="pill" id="autoPill">CHECK</span></div><div class="metric"><span>Gaming-Stack</span><b id="autopilot">—</b></div><div class="metric"><span>Launcher-Updates</span><b id="packageUpdates">—</b></div><div class="metric"><span>Distribution</span><b id="distro">—</b></div><div class="metric"><span>Session</span><b id="session">—</b></div></article>
</section>
<section class="actions"><button data-action="maintain"><strong>Stack aktualisieren</strong><small>Wine, DXVK & Launcher prüfen</small></button><button data-action="repair"><strong>Automatisch reparieren</strong><small>Setup und Launcher heilen</small></button><button data-action="doctor"><strong>Wine-Selbsttest</strong><small>Prefix isoliert prüfen</small></button><button data-action="support"><strong>Support-Paket</strong><small>Diagnose für Hilfe erstellen</small></button></section>
<section class="actions"><button data-action="repair"><strong>Automatisch reparieren</strong><small>Prefix, RSI-Kompatibilität, DXVK & Desktop-Starter</small></button><button data-action="maintain"><strong>Alles aktualisieren</strong><small>Kompatibles Wine, DXVK und RSI prüfen</small></button><button data-action="support"><strong>Support-Paket</strong><small>Logs und Diagnose datensparsam sammeln</small></button></section>
<section class="job hidden" id="job"><div class="job-icon"><div class="spinner"></div><span class="job-symbol hidden" id="jobSymbol">!</span></div><div class="job-body"><b id="jobTitle">Aktion läuft</b><p id="jobText"></p><details class="job-details hidden" id="jobDetails"><summary>Technische Details</summary><pre id="jobRaw"></pre></details></div></section>
<section class="details"><button id="toggle">Erweiterte Informationen</button><div id="advanced" class="hidden"><pre id="raw"></pre><div class="small-actions"><button data-open="logs">Logs öffnen</button><button data-open="downloads">Downloads öffnen</button></div></div></section>
<footer>Citizen Launcher verwaltet den Gaming-Stack im Benutzerkonto. System- und GPU-Treiber bleiben bei deiner Distribution.</footer>
</main><script>window.CITIZEN_BASE="__BASE__";</script><script src="__BASE__/assets/app.js"></script></body></html>
<section class="details"><button id="toggle">Erweiterte Optionen</button><div id="advanced" class="hidden"><div class="advanced-actions"><button data-action="doctor">Wine-Selbsttest</button><button data-action="prepare-system">Linux-System vorbereiten</button><button data-action="choose-prefix">Installationsordner wählen</button><button data-open="logs">Logs öffnen</button><button data-open="downloads">Downloads öffnen</button></div><details><summary>Technischer Status</summary><pre id="raw"></pre></details></div></section>
<footer>Citizen Launcher 1.0 verwaltet Wine, DXVK, RSI-Kompatibilität, Launcher und Updates. Systemtreiber bleiben bei deiner Distribution.</footer>
</main><script>window.CITIZEN_BASE="__BASE__";</script><script src="__BASE__/assets/app.js"></script></body></html>
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+22 -6
View File
@@ -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" <<EOF
install -m644 "$ROOT/packaging/icons/citizen-launcher.svg" "$ICON_DIR/citizen-launcher.svg"
cat > "$APP_DIR/io.github.citizenlauncher.CitizenLauncher.desktop" <<EOF2
[Desktop Entry]
Name=Citizen Launcher
Comment=Star Citizen für Linux – Setup, Start, Updates und Reparatur
Exec=$BIN_DIR/citizen-launcher gui
TryExec=$BIN_DIR/citizen-launcher
Icon=citizen-launcher
Terminal=false
Type=Application
Categories=Game;
StartupNotify=true
StartupWMClass=CitizenLauncher
Keywords=Star Citizen;RSI;Wine;Gaming;
EOF
"$BIN_DIR/citizen-launcher" autopilot enable || true
EOF2
warnings=()
if ! "$BIN_DIR/citizen-launcher" prepare-system; then
warnings+=("Linux-Systemlimits konnten nicht sofort gesetzt werden. Citizen Launcher bietet die Vorbereitung beim Setup erneut an.")
fi
if ! "$BIN_DIR/citizen-launcher" autopilot enable; then
warnings+=("Autopilot konnte nicht automatisch aktiviert werden; der Launcher funktioniert trotzdem und kann ihn später aktivieren.")
fi
if command -v update-desktop-database >/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
+2 -2
View File
@@ -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 || ""
+1 -1
View File
@@ -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}"
+1 -1
View File
@@ -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.",
+36 -10
View File
@@ -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" <<EOF
cat > "$PKG/DEBIAN/control" <<EOF2
Package: citizen-launcher
Version: $VERSION
Section: games
Priority: optional
Architecture: $ARCH
Maintainer: Citizen Launcher Project
Depends: ca-certificates, tar, xdg-utils, curl, cabextract, unzip, xz-utils, apt
Depends: ca-certificates, tar, xdg-utils, curl, cabextract, unzip, xz-utils, apt, procps, util-linux, pkexec
Recommends: pciutils, vulkan-tools
Description: Star Citizen setup, launcher and self-maintaining gaming stack for Linux
Distro-neutral Citizen Launcher with Wine/DXVK/RSI management and local GUI.
EOF
cat > "$PKG/usr/share/applications/io.github.citizenlauncher.CitizenLauncher.desktop" <<EOF
Citizen Launcher manages Wine, DXVK, the RSI Launcher, automatic maintenance,
desktop integration and privacy-conscious support diagnostics.
EOF2
cat > "$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"
@@ -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
+1 -1
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#76e7ff"/><stop offset="1" stop-color="#268dba"/></linearGradient></defs>
<rect width="256" height="256" rx="54" fill="#07131d"/>
<path d="M128 28 151 93 222 103 166 146 184 218 128 178 72 218 90 146 34 103 105 93Z" fill="none" stroke="url(#g)" stroke-width="14" stroke-linejoin="round"/>
<circle cx="128" cy="128" r="23" fill="#5bdcff" opacity=".92"/>
</svg>

After

Width:  |  Height:  |  Size: 506 B

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.github.citizenlauncher.CitizenLauncher</id>
<name>Citizen Launcher</name>
<summary>Set up, launch, repair and maintain Star Citizen on Linux</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<developer id="io.github.citizenlauncher"><name>Citizen Launcher Project</name></developer>
<description>
<p>Citizen Launcher manages a locally compatible Wine runner, DXVK, RSI Launcher compatibility, automatic maintenance and support diagnostics for Star Citizen on Linux.</p>
</description>
<launchable type="desktop-id">io.github.citizenlauncher.CitizenLauncher.desktop</launchable>
<url type="homepage">https://github.com/sendnwv/omarchy-sc</url>
<categories><category>Game</category></categories>
<provides><binary>citizen-launcher</binary></provides>
<releases><release version="1.0.0" date="2026-08-31"/></releases>
<content_rating type="oars-1.1"/>
</component>
+9
View File
@@ -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
+8
View File
@@ -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'
+34
View File
@@ -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'
+10 -3
View File
@@ -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'
+14 -3
View File
@@ -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."