This commit is contained in:
2026-08-31 18:46:57 +02:00
parent 21c588f17a
commit 20fcf46c29
34 changed files with 4971 additions and 210 deletions
+17
View File
@@ -0,0 +1,17 @@
# Architecture
## Layers
1. **Core (`backend/cmd/citizen-launcher`)** – single source of truth for detection, setup, repair, launch and maintenance.
2. **Standalone GUI** – embedded HTML/CSS/JS served only on `127.0.0.1` behind a random per-process URL token.
3. **Desktop integration** – `.desktop` launcher, systemd user maintenance timer when available.
4. **Distribution adapters** – `/etc/os-release` and package-manager detection. The core does not depend on a package manager for Wine/DXVK/RSI.
5. **Omarchy adapter** – optional Quickshell plugin that calls the same Go core.
## Why a local embedded web GUI?
It lets one static Go binary present the same polished UI on Debian, Fedora and Arch without linking to a particular desktop toolkit. The HTTP server binds to loopback only and uses an unguessable route token. No remote content is required to render the UI.
## Update strategy
Gaming components are versioned independently and activated only after local validation. Wine uses newest-compatible fallback rather than newest-at-all-costs. Distribution packages and GPU drivers are outside the self-update trust boundary.
+13
View File
@@ -0,0 +1,13 @@
# Distribution support
Citizen Launcher deliberately avoids distro-specific Wine packages.
| Distribution family | Install method | Autopilot | Notes |
|---|---|---|---|
| Debian / Ubuntu / Mint | `.deb` or `install.sh` | systemd user timer | GPU/Vulkan driver must be working |
| Fedora | generic tarball / `install.sh` | systemd user timer | RPM spec included |
| Arch / Omarchy | `install.sh` | systemd user timer | optional Omarchy bar integration |
| openSUSE | generic tarball / `install.sh` | systemd user timer | package-manager independent core |
| non-systemd desktop | generic install | maintenance on app use | timer is skipped |
The launcher detects `apt`, `dnf`, `pacman`, `zypper` and `apk` for diagnostics/UI, but does not silently alter the base OS.
+64 -45
View File
@@ -1,62 +1,81 @@
# Omarchy Citizen 0.8.1 — Development Preview
# Citizen Launcher 0.9.0
Omarchy Citizen is moving from a frontend around the LUG installer to an
**owned, self-maintaining Star Citizen stack for Omarchy**.
Citizen Launcher is the distro-neutral successor to Omarchy Citizen.
## Goal
**Install Linux → install Citizen Launcher → log into RSI → play.**
The core is one static Go binary and does not depend on Omarchy. Omarchy is an optional integration under `integrations/omarchy/`.
## Supported targets
Primary targets:
- Debian / Ubuntu / Linux Mint
- Fedora
- Arch Linux / Omarchy
- openSUSE (generic user install)
Other amd64 Linux distributions can use the generic tarball when they provide a working Vulkan driver and the runtime requirements of the selected Wine runner.
## GUI
`citizen-launcher gui` starts a localhost-only UI from files embedded in the Go binary. Chromium-family browsers open it as a dedicated app window; otherwise it opens in the default browser. No GTK/Qt/WebKit development/runtime package is required by Citizen Launcher itself.
## Architecture
The critical install and launch path is owned by this project:
1. hardware / Vulkan readiness gate
2. select the newest locally compatible stable LUG Wine runner
3. validate it in a disposable Wine prefix
4. create the real Wine prefix
5. dynamically fetch current Winetricks and install required verbs
6. dynamically fetch and verify the current RSI Launcher
7. manage DXVK and the stable launch entry point
8. periodically maintain the user-space gaming stack
LUG Helper is **optional support/compatibility tooling**. Its failure must not
block installation and it never chooses the active Wine runner.
## Stable paths
User-space components live below:
```text
~/.local/share/omarchy-citizen/
~/.local/state/omarchy-citizen/
~/.config/omarchy-citizen/
Standalone GUI / Omarchy bar integration
↓
Citizen Launcher
Go core
↓
┌───────────────┼────────────────┐
Hardware Wine RSI Launcher
readiness selector installer
+ self-test
↓
Wine prefix
↓
Winetricks + DXVK
↓
Star Citizen
```
The backend intentionally hides upstream version/layout changes behind stable
`vendor/*/current` paths and its own game configuration.
The managed game stack lives in XDG user directories:
## Update safety
- `~/.config/citizen-launcher`
- `~/.local/share/citizen-launcher`
- `~/.local/state/citizen-launcher`
- `~/.cache/citizen-launcher`
- Wine candidates are tested before activation.
- Incompatible immutable Wine releases are cached per CPU/glibc fingerprint.
- Partial prefixes are backed up rather than deleted.
- RSI installer downloads are checked against the SHA-512 value from its
published update metadata.
- DXVK is not changed while the Wine prefix is active.
- Plugin Git updates retain the Omarchy validation / trusted-remote protection.
Existing `omarchy-citizen` data is migrated when possible.
## Current development status
## Build
See `DEVELOPMENT_STATUS.md`.
```bash
./build.sh
```
## License
## Install (any desktop distro)
MIT.
```bash
./install.sh
```
For Omarchy plus bar integration:
## 0.8.1 adapter hotfix
```bash
./install-omarchy.sh
```
The 0.8.0 preview accidentally mixed legacy shell actions with the new Go-owned
stack. `citizenctl` has been rewritten as a thin adapter. There is now one
authoritative implementation for install/status/launch/repair: the Go backend.
## Debian package
A regression test exercises the adapter with a fake backend, including the exact
full-setup path that previously failed with `backend_game_status: command not
found` / `notify: command not found`.
```bash
./packaging/build-deb.sh
```
## Security boundaries
Citizen Launcher manages its Wine/DXVK/RSI stack entirely as the current user. It does not create passwordless sudo/pacman/apt rules. GPU/kernel/base-system updates remain owned by the distribution.
+36
View File
@@ -0,0 +1,36 @@
# Citizen Launcher 0.9.0 — distro-neutral preview
This release turns Omarchy Citizen into an optional integration instead of the product core.
## New
- standalone distro-neutral `citizen-launcher` Go binary
- embedded polished local GUI (`citizen-launcher gui`)
- Debian/Ubuntu `.deb` packaging
- generic Linux amd64 tarball
- Fedora RPM spec template
- distro detection via `/etc/os-release`
- package-manager detection for apt/dnf/pacman/zypper/apk diagnostics
- XDG-native config/data/state/cache paths with legacy Omarchy-Citizen migration
- generic systemd user maintenance timer, with launch-time maintenance fallback on non-systemd desktops
- support bundles generated entirely in Go
- Omarchy moved to `integrations/omarchy/`
- system tools preferred; LUG AppImage runtime is only a lazy portability fallback when cabextract/curl/unzip are unavailable
## Validation performed
- `go test ./...`
- `go vet ./...`
- static Linux amd64 Go build
- shell syntax verification
- embedded GUI HTTP/API smoke test
- generic user installer smoke test in an isolated HOME
- Omarchy updater integration test
- Debian package build and metadata inspection
## Still needs real-hardware testing
- clean Debian/Ubuntu desktop → RSI login/install → Star Citizen launch
- Fedora/openSUSE hardware testing
- AMD/NVIDIA/Intel Vulkan permutations
- production standalone self-update release channel once an official repository/release URL is chosen
+14 -61
View File
@@ -1,69 +1,22 @@
# Omarchy Citizen Go Update Backend
# Citizen Launcher Go Core
`omarchy-citizen-backend` is a small, dependency-free Go helper for plugin updates.
It deliberately does **not** implement its own plugin package manager. For Git-managed
Omarchy plugins it delegates the actual update to:
```bash
omarchy plugin update local.omarchy-citizen --yes
```
This preserves Omarchy's own fast-forward, manifest validation and rollback behavior.
The Go core is the single source of truth for Star Citizen setup, launch, repair and maintenance.
## Commands
```text
omarchy-citizen-backend status [--json]
omarchy-citizen-backend check [--json]
omarchy-citizen-backend update
omarchy-citizen-backend tick
omarchy-citizen-backend auto enable
omarchy-citizen-backend auto disable
omarchy-citizen-backend auto status
omarchy-citizen-backend install-service
omarchy-citizen-backend uninstall-service
omarchy-citizen-backend self-sync
omarchy-citizen-backend version
citizen-launcher gui
citizen-launcher platform [--json]
citizen-launcher game-status [--json]
citizen-launcher game-install
citizen-launcher game-launch
citizen-launcher game-repair
citizen-launcher maintain
citizen-launcher doctor
citizen-launcher support
citizen-launcher autopilot enable|disable|status
```
## Update trust model
The optional Omarchy integration also exposes the legacy integration update commands.
Automatic updates can only be enabled when the plugin directory is a Git checkout.
When the user enables automatic updates, the current `origin` URL is stored as the
trusted remote. Future unattended updates are refused if:
- the Git remote changes,
- the checkout contains local changes,
- the branch has diverged,
- Omarchy validation fails,
- another update is already running.
## Scheduling
`auto enable` creates/enables a systemd **user** timer. It checks approximately every
six hours with a randomized delay. No root service and no localhost HTTP server are used.
## State
```text
~/.config/omarchy-citizen/updater.json
~/.local/state/omarchy-citizen/updater.log
~/.local/state/omarchy-citizen/update.lock
~/.local/lib/omarchy-citizen/omarchy-citizen-backend
```
The external binary location is intentional: the updater can keep running while the
plugin Git checkout itself is fast-forwarded. After a successful plugin update it copies
the newly bundled backend binary into the external location atomically.
## Building
```bash
cd backend
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-trimpath -ldflags='-s -w' \
-o bin/omarchy-citizen-backend \
./cmd/omarchy-citizen-backend
```
The repository should commit the resulting binary so users do not need a Go toolchain.
Build with `./build.sh`. The output is `bin/citizen-launcher`.
Binary file not shown.
+3 -3
View File
@@ -4,6 +4,6 @@ cd "$(dirname "$0")"
mkdir -p bin
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-trimpath -ldflags='-s -w' \
-o bin/omarchy-citizen-backend \
./cmd/omarchy-citizen-backend
sha256sum bin/omarchy-citizen-backend
-o bin/citizen-launcher \
./cmd/citizen-launcher
sha256sum bin/citizen-launcher
+929
View File
@@ -0,0 +1,929 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
)
const (
lugRepo = "starcitizen-lug/lug-helper"
wineRepo = "starcitizen-lug/lug-wine"
dxvkRepo = "doitsujin/dxvk"
)
type githubRelease struct {
TagName string `json:"tag_name"`
Assets []githubAsset `json:"assets"`
}
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Size int64 `json:"size"`
Digest string `json:"digest,omitempty"`
}
type componentMeta struct {
Version string `json:"version"`
Asset string `json:"asset,omitempty"`
URL string `json:"url,omitempty"`
Updated string `json:"updated"`
}
type wineRejection struct {
Version string `json:"version"`
Reason string `json:"reason"`
Fingerprint string `json:"fingerprint"`
Updated string `json:"updated"`
}
func (a *App) enableAutopilot() error {
cfg := a.loadConfig()
cfg.AutoMaintain = true
// The optional Omarchy integration may also be eligible for plugin updates.
st, _ := a.status(true)
if st.Managed == "omarchy-git" && !st.Dirty && st.Remote != "" {
cfg.AutoApply = true
if cfg.TrustedRemote == "" {
cfg.TrustedRemote = st.Remote
}
}
cfg.LastResult = "autopilot-enabled"
a.saveConfig(cfg)
// systemd is common on Debian/Fedora/Arch/openSUSE, but not required by the
// launcher. Non-systemd desktops are maintained opportunistically on launch.
if detectPlatform().SystemdUser {
if err := a.installService(); err != nil {
return err
}
if err := run("", "systemctl", "--user", "enable", "--now", "citizen-launcher-maintenance.timer"); err != nil {
return err
}
} else {
if err := a.selfSync(); err != nil {
return err
}
a.logf("autopilot enabled without systemd user manager; launch-time maintenance active")
}
a.logf("autopilot enabled auto_integration=%v", cfg.AutoApply)
fmt.Println("enabled")
return nil
}
func (a *App) disableAutopilot() error {
cfg := a.loadConfig()
cfg.AutoMaintain = false
cfg.LastResult = "autopilot-disabled"
a.saveConfig(cfg)
a.logf("autopilot disabled")
fmt.Println("disabled")
return nil
}
func (a *App) maintainGamingStack() error {
unlock, err := a.acquireMaintenanceLock()
if err != nil {
return err
}
defer unlock()
a.logf("maintenance begin")
var problems []string
// LUG is no longer synchronized during normal maintenance. It is only a
// lazy portable-toolbox fallback when distro tools are missing.
if err := a.syncWineRunner(); err != nil {
problems = append(problems, "Wine runner: "+err.Error())
}
if err := a.syncDXVK(); err != nil {
problems = append(problems, "DXVK: "+err.Error())
}
if err := a.syncRSILauncherIfNeeded(); err != nil {
problems = append(problems, "RSI Launcher: "+err.Error())
}
result := "ok"
if len(problems) > 0 {
result = strings.Join(problems, " | ")
}
a.updateConfig(func(c *Config) {
c.LastMaintenance = time.Now().Format(time.RFC3339)
c.MaintenanceResult = result
})
a.logf("maintenance end result=%s", result)
if len(problems) > 0 {
return errors.New(result)
}
return nil
}
func (a *App) syncLUGHelper() error {
release, err := githubLatest(lugRepo)
if err != nil {
return err
}
asset, err := selectAsset(release.Assets, func(n string) bool {
n = strings.ToLower(n)
return strings.HasSuffix(n, ".appimage") && strings.Contains(n, "lug-helper")
})
if err != nil {
return fmt.Errorf("release %s: %w", release.TagName, err)
}
dir := filepath.Join(a.vendorDir, "lug-helper")
metaPath := filepath.Join(dir, "meta.json")
target := filepath.Join(dir, "lug-helper.appimage")
if readMetaVersion(metaPath) == release.TagName {
if fi, err := os.Stat(target); err == nil && fi.Mode().IsRegular() {
return nil
}
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp := target + ".new"
if err := download(asset.BrowserDownloadURL, tmp); err != nil {
return err
}
if err := verifyReleaseDigest(tmp, asset.Digest); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Chmod(tmp, 0o755); err != nil {
return err
}
if err := os.Rename(tmp, target); err != nil {
return err
}
if err := writeMeta(metaPath, componentMeta{
Version: release.TagName, Asset: asset.Name, URL: asset.BrowserDownloadURL,
Updated: time.Now().Format(time.RFC3339),
}); err != nil {
return err
}
a.logf("LUG Helper updated to %s asset=%s", release.TagName, asset.Name)
return nil
}
func (a *App) syncWineRunner() error {
releases, err := githubRecent(wineRepo, 10)
if err != nil {
return err
}
if len(releases) == 0 {
return errors.New("LUG Wine repository returned no releases")
}
base := filepath.Join(a.vendorDir, "wine")
metaPath := filepath.Join(base, "meta.json")
current := filepath.Join(base, "current")
rejectPath := filepath.Join(base, "rejected.json")
fingerprint := compatibilityFingerprint()
rejections := readWineRejections(rejectPath)
if err := os.MkdirAll(base, 0o755); err != nil {
return err
}
if err := os.MkdirAll(a.cacheDir, 0o755); err != nil {
return err
}
var failures []string
for _, release := range releases {
asset, assetErr := selectAsset(release.Assets, func(n string) bool {
n = strings.ToLower(n)
if strings.Contains(n, "staging") || strings.Contains(n, "checksum") ||
strings.Contains(n, "sha") || strings.Contains(n, "experimental") ||
strings.Contains(n, "wayland") {
return false
}
return strings.Contains(n, "lug-wine-tkg-git") &&
(strings.HasSuffix(n, ".tar.xz") || strings.HasSuffix(n, ".tar.zst") ||
strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz"))
})
if assetErr != nil {
continue
}
// If this exact immutable release already failed on the same hardware /
// libc environment, do not waste bandwidth testing it every six hours.
if rejection, ok := rejections[release.TagName]; ok && rejection.Fingerprint == fingerprint {
failures = append(failures, release.TagName+" cached-incompatible: "+rejection.Reason)
continue
}
// Already-active compatible version: no download/test needed.
if readMetaVersion(metaPath) == release.TagName {
if p, _ := filepath.EvalSymlinks(current); p != "" {
if _, err := os.Stat(filepath.Join(p, "bin", "wine")); err == nil {
a.activateRunnerForPrefix(p)
return nil
}
}
}
archive := filepath.Join(a.cacheDir, "wine-"+release.TagName+"-"+filepath.Base(asset.Name))
if err := download(asset.BrowserDownloadURL, archive); err != nil {
failures = append(failures, release.TagName+" download: "+err.Error())
continue
}
if err := verifyReleaseDigest(archive, asset.Digest); err != nil {
_ = os.Remove(archive)
failures = append(failures, release.TagName+" checksum: "+err.Error())
continue
}
stage, err := os.MkdirTemp(a.cacheDir, "wine-stage-")
if err != nil {
return err
}
extract := exec.Command("tar", "-xf", archive, "-C", stage)
out, extractErr := extract.CombinedOutput()
if extractErr != nil {
os.RemoveAll(stage)
failures = append(failures, release.TagName+" extract: "+formatCommandFailure(extractErr, out))
continue
}
runnerRoot, err := findRootContaining(stage, filepath.Join("bin", "wine"))
if err != nil {
os.RemoveAll(stage)
failures = append(failures, release.TagName+" layout: "+err.Error())
continue
}
diag, testErr := a.selfTestRunnerDetailed(runnerRoot)
if testErr != nil {
reason := testErr.Error()
a.logf("Wine candidate %s rejected: %s | diagnostic=%s", release.TagName, reason, compactDiagnostic(diag))
rejections[release.TagName] = wineRejection{
Version: release.TagName, Reason: reason, Fingerprint: fingerprint,
Updated: time.Now().Format(time.RFC3339),
}
_ = writeWineRejections(rejectPath, rejections)
failures = append(failures, release.TagName+" incompatible: "+reason)
os.RemoveAll(stage)
_ = os.Remove(archive)
continue
}
target := filepath.Join(base, release.TagName)
_ = os.RemoveAll(target + ".new")
if err := copyTree(runnerRoot, target+".new"); err != nil {
os.RemoveAll(stage)
return err
}
_ = os.RemoveAll(target)
if err := os.Rename(target+".new", target); err != nil {
os.RemoveAll(stage)
return err
}
tmpLink := current + ".new"
_ = os.Remove(tmpLink)
if err := os.Symlink(target, tmpLink); err != nil {
os.RemoveAll(stage)
return err
}
if err := os.Rename(tmpLink, current); err != nil {
os.RemoveAll(stage)
return err
}
if err := writeMeta(metaPath, componentMeta{
Version: release.TagName, Asset: asset.Name, URL: asset.BrowserDownloadURL,
Updated: time.Now().Format(time.RFC3339),
}); err != nil {
os.RemoveAll(stage)
return err
}
delete(rejections, release.TagName)
_ = writeWineRejections(rejectPath, rejections)
a.activateRunnerForPrefix(target)
a.logf("Wine runner activated: %s asset=%s diagnostic=%s", release.TagName, asset.Name, compactDiagnostic(diag))
os.RemoveAll(stage)
_ = os.Remove(archive)
return nil
}
if len(failures) == 0 {
return errors.New("no stable LUG Wine asset found in recent releases")
}
return fmt.Errorf("no compatible stable LUG Wine runner found; %s", strings.Join(failures, " | "))
}
func (a *App) selfTestRunner(runner string) error {
_, err := a.selfTestRunnerDetailed(runner)
return err
}
func (a *App) selfTestRunnerDetailed(runner string) (string, error) {
bin := filepath.Join(runner, "bin")
wine := filepath.Join(bin, "wine")
wineboot := filepath.Join(bin, "wineboot")
wineserver := filepath.Join(bin, "wineserver")
for _, required := range []string{wine, wineboot, wineserver} {
fi, err := os.Stat(required)
if err != nil {
return "", fmt.Errorf("runner is incomplete: %s missing", filepath.Base(required))
}
if fi.Mode()&0o111 == 0 {
return "", fmt.Errorf("runner is incomplete: %s is not executable", filepath.Base(required))
}
}
tmp, err := os.MkdirTemp(a.cacheDir, "wine-selftest-prefix-")
if err != nil {
return "", err
}
defer os.RemoveAll(tmp)
env := append(os.Environ(),
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"WINE="+wine,
"WINEPREFIX="+tmp,
"WINEARCH=win64",
"WINEDEBUG=-all",
"WINEDLLOVERRIDES=winemenubuilder.exe=d",
)
var diagnostic []string
// Capture the runner's own version before prefix creation. This also catches
// loader/CPU failures that otherwise produce an empty wineboot stdout.
versionCmd := exec.Command(wine, "--version")
versionCmd.Env = env
versionOut, versionErr := versionCmd.CombinedOutput()
if versionErr != nil {
return strings.Join(diagnostic, "\n"), fmt.Errorf(
"wine --version failed: %s", formatCommandFailure(versionErr, versionOut))
}
diagnostic = append(diagnostic, "wine="+strings.TrimSpace(string(versionOut)))
// Use the runner-provided wineboot directly, matching how regular Wine
// launchers create a prefix. Never fall back to `wine wineboot`.
bootCmd := exec.Command(wineboot, "-u")
bootCmd.Env = env
bootOut, bootErr := bootCmd.CombinedOutput()
if bootErr != nil {
return strings.Join(diagnostic, "\n"), fmt.Errorf(
"wineboot failed: %s", formatCommandFailure(bootErr, bootOut))
}
if text := strings.TrimSpace(string(bootOut)); text != "" {
diagnostic = append(diagnostic, "wineboot="+text)
}
// Let wineserver finish pending registry writes before validating the prefix.
waitCmd := exec.Command(wineserver, "-w")
waitCmd.Env = env
waitOut, waitErr := waitCmd.CombinedOutput()
if waitErr != nil {
return strings.Join(diagnostic, "\n"), fmt.Errorf(
"wineserver -w failed: %s", formatCommandFailure(waitErr, waitOut))
}
deadline := time.Now().Add(20 * time.Second)
for {
ready := true
for _, p := range []string{"drive_c", "system.reg", "user.reg"} {
if _, err := os.Stat(filepath.Join(tmp, p)); err != nil {
ready = false
break
}
}
if ready {
break
}
if time.Now().After(deadline) {
return strings.Join(diagnostic, "\n"),
errors.New("wineboot returned successfully but did not create drive_c/system.reg/user.reg")
}
time.Sleep(250 * time.Millisecond)
}
cmd := exec.Command(wine, "cmd.exe", "/c", "echo", "%APPDATA%")
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return strings.Join(diagnostic, "\n"), fmt.Errorf(
"APPDATA probe failed: %s", formatCommandFailure(err, out))
}
value := strings.TrimSpace(string(out))
diagnostic = append(diagnostic, "APPDATA="+value)
if value == "" || strings.Contains(value, "%APPDATA%") {
return strings.Join(diagnostic, "\n"), errors.New("Wine returned no usable APPDATA path")
}
killCmd := exec.Command(wineserver, "-k")
killCmd.Env = env
_, _ = killCmd.CombinedOutput()
return strings.Join(diagnostic, "\n"), nil
}
func formatCommandFailure(err error, out []byte) string {
text := strings.TrimSpace(string(out))
if text == "" {
return err.Error()
}
// Keep status output compact but preserve enough detail in updater.log.
if len(text) > 1200 {
text = text[len(text)-1200:]
}
return err.Error() + ": " + text
}
func compactDiagnostic(s string) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > 600 {
return s[:600] + "…"
}
return s
}
func (a *App) wineDoctor() error {
current := filepath.Join(a.vendorDir, "wine", "current")
root, err := filepath.EvalSymlinks(current)
if err != nil {
return errors.New("managed Wine runner is not installed yet")
}
fmt.Println("runner=" + root)
if err := a.selfTestRunner(root); err != nil {
fmt.Println("wine_selftest=failed")
return err
}
fmt.Println("wine_selftest=ok")
return nil
}
func (a *App) activateRunnerForPrefix(runner string) {
prefix := a.configuredPrefix()
if prefix == "" || !prefixInitialized(prefix) || prefixBusy(prefix) {
return
}
launch := filepath.Join(prefix, "sc-launch.sh")
data, err := os.ReadFile(launch)
if err != nil {
return
}
want := `export wine_path="` + filepath.Join(runner, "bin") + `"`
lines := strings.Split(string(data), "\n")
found := false
for i, line := range lines {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "export wine_path=") {
lines[i] = want
found = true
break
}
}
if !found {
return
}
tmp := launch + ".new"
if os.WriteFile(tmp, []byte(strings.Join(lines, "\n")), 0o755) == nil {
_ = os.Rename(tmp, launch)
a.logf("launch script switched to managed Wine %s", runner)
}
}
func (a *App) syncDXVK() error {
prefix := a.configuredPrefix()
if prefix == "" || !prefixInitialized(prefix) {
return nil
}
if prefixBusy(prefix) {
a.logf("DXVK update deferred: Star Citizen Wine prefix is active")
return nil
}
release, err := githubLatest(dxvkRepo)
if err != nil {
return err
}
asset, err := selectAsset(release.Assets, func(n string) bool {
n = strings.ToLower(n)
return strings.HasPrefix(n, "dxvk-") && strings.HasSuffix(n, ".tar.gz")
})
if err != nil {
return err
}
base := filepath.Join(a.vendorDir, "dxvk")
metaPath := filepath.Join(base, "meta.json")
if readMetaVersion(metaPath) == release.TagName {
return nil
}
if err := os.MkdirAll(base, 0o755); err != nil {
return err
}
if err := os.MkdirAll(a.cacheDir, 0o755); err != nil {
return err
}
archive := filepath.Join(a.cacheDir, filepath.Base(asset.Name))
if err := download(asset.BrowserDownloadURL, archive); err != nil {
return err
}
if err := verifyReleaseDigest(archive, asset.Digest); err != nil {
_ = os.Remove(archive)
return err
}
stage, err := os.MkdirTemp(a.cacheDir, "dxvk-stage-")
if err != nil {
return err
}
defer os.RemoveAll(stage)
if out, err := exec.Command("tar", "-xf", archive, "-C", stage).CombinedOutput(); err != nil {
return fmt.Errorf("extract DXVK: %s", strings.TrimSpace(string(out)))
}
root, err := findRootContaining(stage, filepath.Join("x64", "dxgi.dll"))
if err != nil {
return err
}
system32 := filepath.Join(prefix, "drive_c", "windows", "system32")
syswow64 := filepath.Join(prefix, "drive_c", "windows", "syswow64")
backup := filepath.Join(a.stateDir, "dxvk-backup-"+time.Now().Format("20060102-150405"))
_ = os.MkdirAll(backup, 0o700)
if err := installDLLDir(filepath.Join(root, "x64"), system32, backup); err != nil {
return err
}
if _, err := os.Stat(filepath.Join(root, "x32")); err == nil {
if err := installDLLDir(filepath.Join(root, "x32"), syswow64, backup); err != nil {
return err
}
}
if err := writeMeta(metaPath, componentMeta{
Version: release.TagName, Asset: asset.Name, URL: asset.BrowserDownloadURL,
Updated: time.Now().Format(time.RFC3339),
}); err != nil {
return err
}
a.logf("DXVK updated to %s", release.TagName)
_ = os.Remove(archive)
return nil
}
func installDLLDir(srcDir, dstDir, backup string) error {
if err := os.MkdirAll(dstDir, 0o755); err != nil {
return err
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".dll") {
continue
}
src := filepath.Join(srcDir, e.Name())
dst := filepath.Join(dstDir, e.Name())
if _, err := os.Stat(dst); err == nil {
_ = copyFile(dst, filepath.Join(backup, filepath.Base(dstDir)+"-"+e.Name()), 0o644)
}
tmp := dst + ".new"
if err := copyFile(src, tmp, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, dst); err != nil {
return err
}
}
return nil
}
func (a *App) configuredPrefix() string {
return a.loadGameConfig().Prefix
}
func prefixInitialized(prefix string) bool {
for _, p := range []string{"drive_c", "system.reg", "user.reg"} {
if _, err := os.Stat(filepath.Join(prefix, p)); err != nil {
return false
}
}
return true
}
func prefixBusy(prefix string) bool {
proc, err := os.ReadDir("/proc")
if err != nil {
return false
}
needle := []byte("WINEPREFIX=" + prefix)
for _, e := range proc {
if !e.IsDir() {
continue
}
name := e.Name()
if name == "" || name[0] < '0' || name[0] > '9' {
continue
}
data, err := os.ReadFile(filepath.Join("/proc", name, "environ"))
if err == nil && strings.Contains(string(data), string(needle)) {
return true
}
}
return false
}
func githubRecent(repo string, limit int) ([]githubRelease, error) {
if limit <= 0 {
limit = 10
}
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d", repo, limit)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub %s returned HTTP %d", repo, resp.StatusCode)
}
var releases []githubRelease
if err := json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(&releases); err != nil {
return nil, err
}
return releases, nil
}
func compatibilityFingerprint() string {
var parts []string
if out, err := exec.Command("getconf", "GNU_LIBC_VERSION").CombinedOutput(); err == nil {
parts = append(parts, strings.TrimSpace(string(out)))
}
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(strings.ToLower(line), "flags") {
parts = append(parts, strings.TrimSpace(line))
break
}
}
}
if out, err := exec.Command("uname", "-m").CombinedOutput(); err == nil {
parts = append(parts, strings.TrimSpace(string(out)))
}
return strings.Join(parts, " | ")
}
func readWineRejections(path string) map[string]wineRejection {
result := map[string]wineRejection{}
data, err := os.ReadFile(path)
if err != nil {
return result
}
_ = json.Unmarshal(data, &result)
return result
}
func writeWineRejections(path string, values map[string]wineRejection) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(values, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
tmp := path + ".new"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
func githubLatest(repo string) (githubRelease, error) {
var rel githubRelease
req, err := http.NewRequest("GET", "https://api.github.com/repos/"+repo+"/releases/latest", nil)
if err != nil {
return rel, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return rel, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return rel, fmt.Errorf("GitHub %s returned HTTP %d", repo, resp.StatusCode)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&rel); err != nil {
return rel, err
}
if rel.TagName == "" {
return rel, errors.New("release has no tag")
}
return rel, nil
}
func selectAsset(assets []githubAsset, match func(string) bool) (githubAsset, error) {
var candidates []githubAsset
for _, a := range assets {
if match(a.Name) {
candidates = append(candidates, a)
}
}
if len(candidates) == 0 {
return githubAsset{}, errors.New("no matching release asset")
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name < candidates[j].Name })
return candidates[0], nil
}
func download(url, target string) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 20 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
_, copyErr := io.Copy(f, resp.Body)
closeErr := f.Close()
if copyErr != nil {
_ = os.Remove(target)
return copyErr
}
return closeErr
}
func findRootContaining(base, relative string) (string, error) {
var matches []string
err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() && d.Name() == filepath.Base(filepath.Dir(relative)) {
candidate := filepath.Join(filepath.Dir(path), relative)
if _, err := os.Stat(candidate); err == nil {
matches = append(matches, filepath.Dir(path))
}
}
return nil
})
if err != nil {
return "", err
}
if len(matches) == 0 {
// Robust fallback: inspect every directory for the relative path.
_ = filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
if err == nil && d.IsDir() {
if _, e := os.Stat(filepath.Join(path, relative)); e == nil {
matches = append(matches, path)
}
}
return nil
})
}
if len(matches) == 0 {
return "", fmt.Errorf("archive does not contain %s", relative)
}
sort.Strings(matches)
return matches[0], nil
}
func copyTree(src, dst string) error {
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
info, err := d.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
link, err := os.Readlink(path)
if err != nil {
return err
}
return os.Symlink(link, target)
}
mode := info.Mode().Perm()
if mode == 0 {
mode = 0o644
}
return copyFile(path, target, mode)
})
}
func readMetaVersion(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
var meta componentMeta
if json.Unmarshal(data, &meta) != nil {
return ""
}
return meta.Version
}
func writeMeta(path string, meta componentMeta) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, _ := json.MarshalIndent(meta, "", " ")
data = append(data, '\n')
tmp := path + ".new"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func (a *App) saveConfig(cfg Config) {
_ = os.MkdirAll(a.configDir, 0o755)
data, _ := json.MarshalIndent(cfg, "", " ")
data = append(data, '\n')
tmp := a.configPath + ".tmp"
if os.WriteFile(tmp, data, 0o600) == nil {
_ = os.Rename(tmp, a.configPath)
}
}
func (a *App) acquireMaintenanceLock() (func(), error) {
if err := os.MkdirAll(a.stateDir, 0o755); err != nil {
return nil, err
}
path := filepath.Join(a.stateDir, "maintenance.lock")
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
return nil, errors.New("another Citizen Launcher maintenance run is active")
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
}, nil
}
func verifyReleaseDigest(path, digest string) error {
if digest == "" {
return nil
}
parts := strings.SplitN(digest, ":", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "sha256" {
return nil
}
want := strings.ToLower(strings.TrimSpace(parts[1]))
got, err := fileHash(path)
if err != nil {
return err
}
if strings.ToLower(got) != want {
return fmt.Errorf("SHA-256 mismatch for %s", filepath.Base(path))
}
return nil
}
@@ -0,0 +1,69 @@
package main
import (
"fmt"
"path/filepath"
"strings"
"testing"
)
func TestSelectLUGAppImage(t *testing.T) {
assets := []githubAsset{
{Name: "lug-helper-v4.16.tar.gz"},
{Name: "lug-helper-v4.16.appimage"},
{Name: "lug-helper-v4.16.appimage.zsync"},
}
a, err := selectAsset(assets, func(n string) bool {
n = lower(n)
return suffix(n, ".appimage") && contains(n, "lug-helper")
})
if err != nil || a.Name != "lug-helper-v4.16.appimage" {
t.Fatalf("unexpected selection: %#v %v", a, err)
}
}
func TestSelectStableWine(t *testing.T) {
assets := []githubAsset{
{Name: "lug-wine-tkg-staging-git-11.16-1-x86_64.tar.xz"},
{Name: "lug-wine-tkg-git-11.16-1-x86_64.tar.xz"},
{Name: "SHA256SUMS"},
}
a, err := selectAsset(assets, func(n string) bool {
n = lower(n)
if contains(n, "staging") || contains(n, "checksum") || contains(n, "sha") {
return false
}
return contains(n, "lug-wine-tkg-git") && suffix(n, ".tar.xz")
})
if err != nil || a.Name != "lug-wine-tkg-git-11.16-1-x86_64.tar.xz" {
t.Fatalf("unexpected selection: %#v %v", a, err)
}
}
// tiny wrappers keep tests readable without importing strings again
func lower(s string) string { return strings.ToLower(s) }
func suffix(s, p string) bool { return strings.HasSuffix(s, p) }
func contains(s, p string) bool { return strings.Contains(s, p) }
func TestFormatCommandFailureIncludesProcessError(t *testing.T) {
err := fmt.Errorf("signal: illegal instruction")
got := formatCommandFailure(err, nil)
if !strings.Contains(got, "illegal instruction") {
t.Fatalf("process error was lost: %q", got)
}
}
func TestWineRejectionRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "rejected.json")
in := map[string]wineRejection{
"11.16-1": {Version: "11.16-1", Reason: "signal: illegal instruction", Fingerprint: "cpu-a"},
}
if err := writeWineRejections(path, in); err != nil {
t.Fatal(err)
}
out := readWineRejections(path)
if out["11.16-1"].Reason != in["11.16-1"].Reason {
t.Fatalf("round trip mismatch: %#v", out)
}
}
+204
View File
@@ -0,0 +1,204 @@
package main
import (
"crypto/rand"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
//go:embed web/*
var guiFiles embed.FS
type GUIStatus struct {
Version string `json:"version"`
Platform PlatformStatus `json:"platform"`
Game GameStatus `json:"game"`
Launcher Status `json:"launcher"`
}
type GUIJob struct {
ID string `json:"id"`
Action string `json:"action"`
State string `json:"state"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
Started string `json:"started"`
Finished string `json:"finished,omitempty"`
}
type jobStore struct {
mu sync.Mutex
jobs map[string]*GUIJob
}
func (a *App) runGUI(args []string) error {
noOpen := hasArg(args, "--no-open")
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
tokenBytes := make([]byte, 18)
_, _ = rand.Read(tokenBytes)
token := hex.EncodeToString(tokenBytes)
base := "/" + token
jobs := &jobStore{jobs: map[string]*GUIJob{}}
mux := http.NewServeMux()
sub, _ := fs.Sub(guiFiles, "web")
mux.Handle(base+"/assets/", http.StripPrefix(base+"/assets/", http.FileServer(http.FS(sub))))
mux.HandleFunc(base+"/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != base+"/" {
http.NotFound(w, r)
return
}
b, err := fs.ReadFile(sub, "index.html")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
b = []byte(strings.ReplaceAll(string(b), "__BASE__", base))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(b)
})
jsonOut := func(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
mux.HandleFunc(base+"/api/status", func(w http.ResponseWriter, r *http.Request) {
st, _ := a.status(false)
jsonOut(w, GUIStatus{Version: appVersion, Platform: detectPlatform(), Game: a.gameStatus(), Launcher: st})
})
mux.HandleFunc(base+"/api/job/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, base+"/api/job/")
jobs.mu.Lock()
j := jobs.jobs[id]
jobs.mu.Unlock()
if j == nil {
http.NotFound(w, r)
return
}
jsonOut(w, j)
})
mux.HandleFunc(base+"/api/action/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", 405)
return
}
action := strings.TrimPrefix(r.URL.Path, base+"/api/action/")
if action == "launch" {
if err := a.gameLaunch(); err != nil {
http.Error(w, err.Error(), 500)
return
}
jsonOut(w, map[string]string{"state": "started"})
return
}
idBytes := make([]byte, 8)
_, _ = rand.Read(idBytes)
id := hex.EncodeToString(idBytes)
j := &GUIJob{ID: id, Action: action, State: "running", Started: time.Now().Format(time.RFC3339)}
jobs.mu.Lock()
jobs.jobs[id] = j
jobs.mu.Unlock()
go func() {
var err error
var msg string
switch action {
case "setup":
if e := a.enableAutopilot(); e != nil {
a.logf("autopilot enable during setup: %v", e)
}
err = a.gameInstall()
case "repair":
err = a.gameRepair()
case "maintain":
err = a.maintainGamingStack()
case "doctor":
err = a.wineDoctor()
case "autopilot-enable":
err = a.enableAutopilot()
case "autopilot-disable":
err = a.disableAutopilot()
case "support":
var p string
p, err = a.createSupportBundle()
msg = p
case "integration-update":
err = a.update(false)
default:
err = errors.New("unknown action")
}
jobs.mu.Lock()
defer jobs.mu.Unlock()
j.Finished = time.Now().Format(time.RFC3339)
if err != nil {
j.State = "error"
j.Error = err.Error()
} else {
j.State = "done"
j.Message = msg
}
}()
jsonOut(w, j)
})
mux.HandleFunc(base+"/api/open/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", 405)
return
}
what := strings.TrimPrefix(r.URL.Path, base+"/api/open/")
var path string
switch what {
case "downloads":
path = filepath.Join(a.home, "Downloads")
case "logs":
path = a.stateDir
default:
http.NotFound(w, r)
return
}
_ = exec.Command("xdg-open", path).Start()
jsonOut(w, map[string]string{"state": "opened"})
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
url := "http://" + listener.Addr().String() + base + "/"
fmt.Println(url)
if !noOpen {
if err := openGUIURL(url, a.cacheDir); err != nil {
_ = listener.Close()
return err
}
}
return srv.Serve(listener)
}
func openGUIURL(url, cache string) error {
candidates := []string{"chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "brave-browser", "brave", "vivaldi", "vivaldi-stable"}
for _, name := range candidates {
if p, err := exec.LookPath(name); err == nil {
cmd := exec.Command(p, "--app="+url, "--new-window")
cmd.Stdout = nil
cmd.Stderr = nil
if cmd.Start() == nil {
return nil
}
}
}
if p, err := exec.LookPath("xdg-open"); err == nil {
return exec.Command(p, url).Start()
}
if p, err := exec.LookPath("gio"); err == nil {
return exec.Command(p, "open", url).Start()
}
return fmt.Errorf("no browser opener found; open %s manually", url)
}
+846
View File
@@ -0,0 +1,846 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
const (
appVersion = "0.9.0"
pluginID = "local.omarchy-citizen"
appID = "io.github.citizenlauncher.CitizenLauncher"
)
type Config struct {
AutoApply bool `json:"auto_apply"`
AutoMaintain bool `json:"auto_maintain"`
TrustedRemote string `json:"trusted_remote,omitempty"`
LastCheck string `json:"last_check,omitempty"`
LastUpdate string `json:"last_update,omitempty"`
LastResult string `json:"last_result,omitempty"`
LastMaintenance string `json:"last_maintenance,omitempty"`
MaintenanceResult string `json:"maintenance_result,omitempty"`
}
type Status struct {
BackendVersion string `json:"backend_version"`
PluginID string `json:"plugin_id"`
PluginDir string `json:"plugin_dir"`
Managed string `json:"managed"`
AutoApply bool `json:"auto_apply"`
AutoMaintain bool `json:"auto_maintain"`
LUGVersion string `json:"lug_version,omitempty"`
WineVersion string `json:"wine_version,omitempty"`
DXVKVersion string `json:"dxvk_version,omitempty"`
LastMaintenance string `json:"last_maintenance,omitempty"`
MaintenanceResult string `json:"maintenance_result,omitempty"`
Remote string `json:"remote,omitempty"`
TrustedRemote string `json:"trusted_remote,omitempty"`
Branch string `json:"branch,omitempty"`
LocalCommit string `json:"local_commit,omitempty"`
RemoteCommit string `json:"remote_commit,omitempty"`
Dirty bool `json:"dirty"`
Update string `json:"update"`
LastCheck string `json:"last_check,omitempty"`
LastUpdate string `json:"last_update,omitempty"`
LastResult string `json:"last_result,omitempty"`
}
type App struct {
home string
pluginDir string
configDir string
stateDir string
dataDir string
cacheDir string
vendorDir string
configPath string
logPath string
libDir string
selfPath string
}
func main() {
app, err := newApp()
if err != nil {
fatal(err)
}
app.rotateLog()
args := os.Args[1:]
if len(args) == 0 {
args = []string{"status"}
}
switch args[0] {
case "version", "--version", "-v":
fmt.Println(appVersion)
case "status":
st, _ := app.status(false)
if hasArg(args[1:], "--json") {
printJSON(st)
} else {
printStatusKV(st)
}
case "check":
st, err := app.status(true)
app.recordCheck(st, err)
if hasArg(args[1:], "--json") {
printJSON(st)
} else {
printStatusKV(st)
}
if err != nil {
os.Exit(2)
}
case "update":
if err := app.update(false); err != nil {
app.logf("manual update failed: %v", err)
fatal(err)
}
case "tick":
if err := app.tick(); err != nil {
app.logf("tick failed: %v", err)
os.Exit(2)
}
case "maintain":
if err := app.maintainGamingStack(); err != nil {
app.logf("maintenance failed: %v", err)
fatal(err)
}
case "doctor":
if err := app.wineDoctor(); err != nil {
fatal(err)
}
case "game-status":
st := app.gameStatus()
if hasArg(args[1:], "--json") {
printJSON(st)
} else {
printGameStatusKV(st)
}
case "game-install":
if err := app.gameInstall(); err != nil {
fatal(err)
}
case "game-launch":
if err := app.gameLaunch(); err != nil {
fatal(err)
}
case "game-repair":
if err := app.gameRepair(); err != nil {
fatal(err)
}
case "gui":
if err := app.runGUI(args[1:]); err != nil {
fatal(err)
}
case "platform":
ps := detectPlatform()
if hasArg(args[1:], "--json") {
printJSON(ps)
} else {
printPlatformKV(ps)
}
case "support":
path, err := app.createSupportBundle()
if err != nil {
fatal(err)
}
fmt.Println(path)
case "autopilot":
if len(args) < 2 {
fatal(errors.New("usage: autopilot enable|disable|status"))
}
switch args[1] {
case "enable":
if err := app.enableAutopilot(); err != nil {
fatal(err)
}
case "disable":
if err := app.disableAutopilot(); err != nil {
fatal(err)
}
case "status":
cfg := app.loadConfig()
if cfg.AutoMaintain {
fmt.Println("enabled")
} else {
fmt.Println("disabled")
}
default:
fatal(errors.New("usage: autopilot enable|disable|status"))
}
case "auto":
if len(args) < 2 {
fatal(errors.New("usage: auto enable|disable|status"))
}
switch args[1] {
case "enable":
if err := app.enableAuto(); err != nil {
fatal(err)
}
case "disable":
if err := app.disableAuto(); err != nil {
fatal(err)
}
case "status":
cfg := app.loadConfig()
if cfg.AutoApply {
fmt.Println("enabled")
} else {
fmt.Println("disabled")
}
default:
fatal(errors.New("usage: auto enable|disable|status"))
}
case "install-service":
if err := app.installService(); err != nil {
fatal(err)
}
case "uninstall-service":
if err := app.uninstallService(); err != nil {
fatal(err)
}
case "self-sync":
if err := app.selfSync(); err != nil {
fatal(err)
}
default:
fatal(fmt.Errorf("unknown command: %s", args[0]))
}
}
func newApp() (*App, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
self, _ := os.Executable()
self, _ = filepath.EvalSymlinks(self)
configHome := envOr("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
stateHome := envOr("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
dataHome := envOr("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
cacheHome := envOr("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
a := &App{
home: home,
pluginDir: filepath.Join(configHome, "omarchy", "plugins", pluginID),
configDir: filepath.Join(configHome, "citizen-launcher"),
stateDir: filepath.Join(stateHome, "citizen-launcher"),
dataDir: filepath.Join(dataHome, "citizen-launcher"),
cacheDir: filepath.Join(cacheHome, "citizen-launcher"),
configPath: filepath.Join(configHome, "citizen-launcher", "updater.json"),
logPath: filepath.Join(stateHome, "citizen-launcher", "updater.log"),
libDir: filepath.Join(home, ".local", "lib", "citizen-launcher"),
selfPath: self,
}
a.vendorDir = filepath.Join(a.dataDir, "vendor")
_ = a.migrateLegacyPaths(configHome, stateHome, dataHome)
return a, nil
}
func (a *App) migrateLegacyPaths(configHome, stateHome, dataHome string) error {
pairs := [][2]string{
{filepath.Join(configHome, "omarchy-citizen"), a.configDir},
{filepath.Join(stateHome, "omarchy-citizen"), a.stateDir},
{filepath.Join(dataHome, "omarchy-citizen"), a.dataDir},
}
for _, pair := range pairs {
if _, err := os.Stat(pair[1]); err == nil {
continue
}
if fi, err := os.Stat(pair[0]); err == nil && fi.IsDir() {
_ = os.MkdirAll(filepath.Dir(pair[1]), 0o755)
if err := os.Rename(pair[0], pair[1]); err != nil {
// Cross-filesystem and permission-safe fallback: leave legacy data in place.
// loadGameConfig() separately adopts the legacy Star Citizen prefix.
a.logf("legacy migration warning %s -> %s: %v", pair[0], pair[1], err)
}
}
}
return nil
}
func (a *App) status(fetch bool) (Status, error) {
cfg := a.loadConfig()
st := Status{
BackendVersion: appVersion,
PluginID: pluginID,
PluginDir: a.pluginDir,
Managed: "manual",
AutoApply: cfg.AutoApply,
AutoMaintain: cfg.AutoMaintain,
LUGVersion: readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json")),
WineVersion: readMetaVersion(filepath.Join(a.vendorDir, "wine", "meta.json")),
DXVKVersion: readMetaVersion(filepath.Join(a.vendorDir, "dxvk", "meta.json")),
LastMaintenance: cfg.LastMaintenance,
MaintenanceResult: cfg.MaintenanceResult,
TrustedRemote: cfg.TrustedRemote,
Update: "unavailable",
LastCheck: cfg.LastCheck,
LastUpdate: cfg.LastUpdate,
LastResult: cfg.LastResult,
}
if !isGitRepo(a.pluginDir) || !commandExists("omarchy") {
st.Managed = "standalone"
st.Update = "external-release"
return st, nil
}
st.Managed = "omarchy-git"
remote, _ := git(a.pluginDir, "remote", "get-url", "origin")
st.Remote = strings.TrimSpace(remote)
branch, _ := git(a.pluginDir, "branch", "--show-current")
st.Branch = strings.TrimSpace(branch)
local, _ := git(a.pluginDir, "rev-parse", "HEAD")
st.LocalCommit = short(strings.TrimSpace(local))
dirty, _ := git(a.pluginDir, "status", "--porcelain")
st.Dirty = strings.TrimSpace(dirty) != ""
if fetch {
if st.Dirty {
st.Update = "blocked-dirty"
return st, errors.New("plugin checkout has local changes")
}
if cfg.TrustedRemote != "" && st.Remote != cfg.TrustedRemote {
st.Update = "blocked-remote-changed"
return st, errors.New("git origin differs from trusted remote")
}
if _, err := git(a.pluginDir, "fetch", "--quiet", "origin"); err != nil {
st.Update = "check-failed"
return st, err
}
}
upstream, err := git(a.pluginDir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
if err != nil {
st.Update = "no-upstream"
return st, nil
}
upstream = strings.TrimSpace(upstream)
remoteCommit, err := git(a.pluginDir, "rev-parse", upstream)
if err != nil {
st.Update = "unknown"
return st, err
}
remoteCommit = strings.TrimSpace(remoteCommit)
st.RemoteCommit = short(remoteCommit)
localFull, _ := git(a.pluginDir, "rev-parse", "HEAD")
localFull = strings.TrimSpace(localFull)
switch {
case st.Dirty:
st.Update = "blocked-dirty"
case localFull == remoteCommit:
st.Update = "current"
default:
ancestor, err := runExit(a.pluginDir, "git", "merge-base", "--is-ancestor", localFull, remoteCommit)
if err == nil && ancestor == 0 {
st.Update = "available"
} else {
st.Update = "diverged"
}
}
return st, nil
}
func (a *App) tick() error {
cfg := a.loadConfig()
var problems []string
if cfg.AutoMaintain {
if err := a.maintainGamingStack(); err != nil {
problems = append(problems, "gaming stack: "+err.Error())
}
}
if cfg.AutoApply {
st, err := a.status(true)
a.recordCheck(st, err)
if err != nil {
problems = append(problems, "plugin check: "+err.Error())
} else if st.Update == "available" {
if err := a.update(true); err != nil {
problems = append(problems, "plugin update: "+err.Error())
}
}
}
if len(problems) > 0 {
return errors.New(strings.Join(problems, "; "))
}
return nil
}
func (a *App) update(automatic bool) error {
unlock, err := a.acquireUpdateLock()
if err != nil {
return err
}
defer unlock()
st, err := a.status(true)
a.recordCheck(st, err)
if err != nil {
return err
}
if st.Managed != "omarchy-git" {
return errors.New("this update path is only available for the optional Omarchy Git integration")
}
cfg := a.loadConfig()
if cfg.TrustedRemote != "" && st.Remote != cfg.TrustedRemote {
return errors.New("refusing update: git origin changed after auto-update trust was granted")
}
if st.Dirty {
return errors.New("refusing update: plugin contains local changes")
}
if st.Update == "current" {
a.logf("update: already current at %s", st.LocalCommit)
return nil
}
if st.Update != "available" {
return fmt.Errorf("update cannot be applied in state %q", st.Update)
}
before := st.LocalCommit
a.logf("update begin automatic=%v remote=%q from=%s to=%s", automatic, st.Remote, st.LocalCommit, st.RemoteCommit)
cmd := exec.Command("omarchy", "plugin", "update", pluginID, "--yes")
cmd.Stdout = io.MultiWriter(os.Stdout, a.logWriter())
cmd.Stderr = io.MultiWriter(os.Stderr, a.logWriter())
if err := cmd.Run(); err != nil {
a.updateConfig(func(c *Config) {
c.LastResult = "update-failed"
})
return fmt.Errorf("omarchy plugin update failed: %w", err)
}
if err := a.validatePlugin(); err != nil {
a.updateConfig(func(c *Config) {
c.LastResult = "post-validation-failed"
})
return fmt.Errorf("post-update validation failed: %w", err)
}
after, _ := git(a.pluginDir, "rev-parse", "HEAD")
after = short(strings.TrimSpace(after))
a.updateConfig(func(c *Config) {
c.LastUpdate = time.Now().Format(time.RFC3339)
c.LastResult = "updated:" + before + "->" + after
})
a.logf("update success from=%s to=%s", before, after)
a.notify("Citizen Launcher", "Plugin-Update installiert: "+before+" → "+after)
if err := a.selfSync(); err != nil {
a.logf("backend self-sync warning: %v", err)
}
_ = run("", "omarchy-shell", "shell", "rescanPlugins")
if !automatic {
// Current Omarchy releases can keep stale third-party bar QML after a rescan.
// A user-triggered update may safely request a full shell restart so the
// freshly validated plugin code becomes active immediately.
_ = run("", "omarchy", "restart", "shell")
}
return nil
}
func (a *App) enableAuto() error {
st, err := a.status(true)
if err != nil {
return err
}
if st.Managed != "omarchy-git" || st.Remote == "" {
return errors.New("integration auto-updates require the optional Omarchy Git integration")
}
if st.Dirty {
return errors.New("cannot enable auto updates while the plugin checkout has local changes")
}
if err := a.validatePlugin(); err != nil {
return err
}
if err := a.installService(); err != nil {
return err
}
a.updateConfig(func(c *Config) {
c.AutoApply = true
c.TrustedRemote = st.Remote
c.LastResult = "auto-enabled"
})
if err := run("", "systemctl", "--user", "enable", "--now", "citizen-launcher-maintenance.timer"); err != nil {
return err
}
a.logf("auto updates enabled trusted_remote=%q", st.Remote)
fmt.Println("enabled")
return nil
}
func (a *App) disableAuto() error {
a.updateConfig(func(c *Config) {
c.AutoApply = false
c.LastResult = "auto-disabled"
})
_ = run("", "systemctl", "--user", "disable", "--now", "citizen-launcher-maintenance.timer")
a.logf("auto updates disabled")
fmt.Println("disabled")
return nil
}
func (a *App) installService() error {
if err := os.MkdirAll(filepath.Join(a.home, ".config", "systemd", "user"), 0o755); err != nil {
return err
}
if err := os.MkdirAll(a.libDir, 0o755); err != nil {
return err
}
if err := a.selfSync(); err != nil {
return err
}
servicePath := filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.service")
timerPath := filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.timer")
backendPath := filepath.Join(a.libDir, "citizen-launcher")
service := `[Unit]\nDescription=Citizen Launcher Autopilot maintenance\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart=` + backendPath + ` tick\n`
timer := `[Unit]\nDescription=Maintain Citizen Launcher gaming stack automatically\n\n[Timer]\nOnBootSec=3min\nOnUnitActiveSec=6h\nRandomizedDelaySec=15min\nPersistent=true\nUnit=citizen-launcher-maintenance.service\n\n[Install]\nWantedBy=timers.target\n`
if err := os.WriteFile(servicePath, []byte(strings.ReplaceAll(service, `\n`, "\n")), 0o644); err != nil {
return err
}
if err := os.WriteFile(timerPath, []byte(strings.ReplaceAll(timer, `\n`, "\n")), 0o644); err != nil {
return err
}
return run("", "systemctl", "--user", "daemon-reload")
}
func (a *App) uninstallService() error {
_ = run("", "systemctl", "--user", "disable", "--now", "citizen-launcher-maintenance.timer")
_ = os.Remove(filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.service"))
_ = os.Remove(filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.timer"))
_ = run("", "systemctl", "--user", "daemon-reload")
return nil
}
func (a *App) selfSync() error {
if err := os.MkdirAll(a.libDir, 0o755); err != nil {
return err
}
target := filepath.Join(a.libDir, "citizen-launcher")
source := a.selfPath
// Omarchy integration ships a bundled copy; prefer it when present.
bundled := filepath.Join(a.pluginDir, "backend", "bin", "citizen-launcher")
if _, err := os.Stat(bundled); err == nil {
source = bundled
}
if source == "" {
return errors.New("cannot locate Citizen Launcher executable")
}
same, _ := sameFileHash(source, target)
if !same {
tmp := target + ".new"
if err := copyFile(source, tmp, 0o755); err != nil {
return err
}
if err := os.Rename(tmp, target); err != nil {
return err
}
}
// Compatibility alias for older Omarchy Citizen adapters.
legacyDir := filepath.Join(a.home, ".local", "lib", "omarchy-citizen")
_ = os.MkdirAll(legacyDir, 0o755)
legacy := filepath.Join(legacyDir, "omarchy-citizen-backend")
_ = os.Remove(legacy)
_ = os.Symlink(target, legacy)
a.logf("self-sync installed %s", target)
return nil
}
func (a *App) acquireUpdateLock() (func(), error) {
if err := os.MkdirAll(a.stateDir, 0o755); err != nil {
return nil, err
}
path := filepath.Join(a.stateDir, "update.lock")
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
return nil, errors.New("another Omarchy Citizen update is already running")
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
}, nil
}
func (a *App) notify(title, body string) {
if _, err := exec.LookPath("omarchy-notification-send"); err == nil {
_ = exec.Command("omarchy-notification-send", title, body).Run()
return
}
if _, err := exec.LookPath("notify-send"); err == nil {
_ = exec.Command("notify-send", title, body).Run()
}
}
func (a *App) validatePlugin() error {
cmd := exec.Command("omarchy", "plugin", "validate", a.pluginDir)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("plugin validation failed: %s", strings.TrimSpace(string(out)))
}
return nil
}
func (a *App) recordCheck(st Status, err error) {
a.updateConfig(func(c *Config) {
c.LastCheck = time.Now().Format(time.RFC3339)
if err != nil {
c.LastResult = "check-failed:" + err.Error()
} else {
c.LastResult = "check:" + st.Update
}
})
a.logf("check managed=%s update=%s local=%s remote=%s dirty=%v err=%v", st.Managed, st.Update, st.LocalCommit, st.RemoteCommit, st.Dirty, err)
}
func (a *App) loadConfig() Config {
var cfg Config
data, err := os.ReadFile(a.configPath)
if err == nil {
_ = json.Unmarshal(data, &cfg)
}
return cfg
}
func (a *App) updateConfig(fn func(*Config)) {
cfg := a.loadConfig()
fn(&cfg)
_ = os.MkdirAll(a.configDir, 0o755)
data, _ := json.MarshalIndent(cfg, "", " ")
data = append(data, '\n')
tmp := a.configPath + ".tmp"
if os.WriteFile(tmp, data, 0o600) == nil {
_ = os.Rename(tmp, a.configPath)
}
}
func (a *App) rotateLog() {
_ = os.MkdirAll(a.stateDir, 0o755)
info, err := os.Stat(a.logPath)
if err != nil || info.Size() <= 4*1024*1024 {
return
}
f, err := os.Open(a.logPath)
if err != nil {
return
}
defer f.Close()
keep := int64(2 * 1024 * 1024)
if _, err := f.Seek(-keep, io.SeekEnd); err != nil {
return
}
data, _ := io.ReadAll(f)
_ = os.WriteFile(a.logPath, data, 0o600)
}
func (a *App) logWriter() io.Writer {
_ = os.MkdirAll(a.stateDir, 0o755)
f, err := os.OpenFile(a.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return io.Discard
}
return &autoCloseWriter{f: f}
}
func (a *App) logf(format string, args ...any) {
_ = os.MkdirAll(a.stateDir, 0o755)
f, err := os.OpenFile(a.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "%s version=%s %s\n", time.Now().Format(time.RFC3339), appVersion, fmt.Sprintf(format, args...))
}
type autoCloseWriter struct{ f *os.File }
func (w *autoCloseWriter) Write(p []byte) (int, error) { return w.f.Write(p) }
func git(dir string, args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out)))
}
return string(out), nil
}
func run(dir, name string, args ...string) error {
cmd := exec.Command(name, args...)
if dir != "" {
cmd.Dir = dir
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func runExit(dir, name string, args ...string) (int, error) {
cmd := exec.Command(name, args...)
if dir != "" {
cmd.Dir = dir
}
err := cmd.Run()
if err == nil {
return 0, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode(), nil
}
return -1, err
}
func isGitRepo(dir string) bool {
out, err := git(dir, "rev-parse", "--is-inside-work-tree")
return err == nil && strings.TrimSpace(out) == "true"
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if err := out.Sync(); err != nil {
out.Close()
return err
}
return out.Close()
}
func sameFileHash(a, b string) (bool, error) {
ha, err := fileHash(a)
if err != nil {
return false, err
}
hb, err := fileHash(b)
if err != nil {
return false, err
}
return ha == hb, nil
}
func fileHash(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func short(s string) string {
if len(s) > 12 {
return s[:12]
}
return s
}
func hasArg(args []string, needle string) bool {
for _, a := range args {
if a == needle {
return true
}
}
return false
}
func printJSON(v any) { enc := json.NewEncoder(os.Stdout); enc.SetIndent("", " "); _ = enc.Encode(v) }
func printStatusKV(s Status) {
fmt.Printf("backend_version=%s\n", s.BackendVersion)
fmt.Printf("managed=%s\n", s.Managed)
fmt.Printf("auto_apply=%t\n", s.AutoApply)
fmt.Printf("auto_maintain=%t\n", s.AutoMaintain)
fmt.Printf("lug_version=%s\n", s.LUGVersion)
fmt.Printf("wine_version=%s\n", s.WineVersion)
fmt.Printf("dxvk_version=%s\n", s.DXVKVersion)
fmt.Printf("last_maintenance=%s\n", s.LastMaintenance)
fmt.Printf("maintenance_result=%s\n", s.MaintenanceResult)
fmt.Printf("update=%s\n", s.Update)
fmt.Printf("remote=%s\n", s.Remote)
fmt.Printf("trusted_remote=%s\n", s.TrustedRemote)
fmt.Printf("branch=%s\n", s.Branch)
fmt.Printf("local_commit=%s\n", s.LocalCommit)
fmt.Printf("remote_commit=%s\n", s.RemoteCommit)
fmt.Printf("dirty=%t\n", s.Dirty)
fmt.Printf("last_check=%s\n", s.LastCheck)
fmt.Printf("last_update=%s\n", s.LastUpdate)
fmt.Printf("last_result=%s\n", s.LastResult)
}
func commandExists(name string) bool { _, err := exec.LookPath(name); return err == nil }
func printPlatformKV(p PlatformStatus) {
fmt.Printf("id=%s\n", p.ID)
fmt.Printf("name=%s\n", p.Name)
fmt.Printf("version=%s\n", p.Version)
fmt.Printf("id_like=%s\n", p.IDLike)
fmt.Printf("package_manager=%s\n", p.PackageManager)
fmt.Printf("desktop=%s\n", p.Desktop)
fmt.Printf("session=%s\n", p.Session)
fmt.Printf("systemd_user=%t\n", p.SystemdUser)
fmt.Printf("omarchy=%t\n", p.Omarchy)
}
func fatal(err error) { fmt.Fprintln(os.Stderr, "citizen-launcher:", err); os.Exit(1) }
func printGameStatusKV(s GameStatus) {
fmt.Printf("backend_version=%s\n", s.BackendVersion)
fmt.Printf("health=%s\n", s.Health)
fmt.Printf("hardware=%s\n", s.Hardware)
fmt.Printf("hardware_reason=%s\n", s.HardwareReason)
fmt.Printf("gpu=%s\n", s.GPU)
fmt.Printf("vulkan=%s\n", s.Vulkan)
fmt.Printf("cpu_avx=%t\n", s.CPUAVX)
fmt.Printf("ram_gib=%d\n", s.RAMGiB)
fmt.Printf("combined_gib=%d\n", s.CombinedGiB)
fmt.Printf("disk_free_gib=%d\n", s.DiskFreeGiB)
fmt.Printf("prefix=%s\n", s.Prefix)
fmt.Printf("prefix_state=%s\n", s.PrefixState)
fmt.Printf("launcher_state=%s\n", s.LauncherState)
fmt.Printf("game_state=%s\n", s.GameState)
fmt.Printf("wine_version=%s\n", s.WineVersion)
fmt.Printf("dxvk_version=%s\n", s.DXVKVersion)
fmt.Printf("lug_version=%s\n", s.LUGVersion)
fmt.Printf("rsi_installer=%s\n", s.RSIInstaller)
fmt.Printf("autopilot=%t\n", s.Autopilot)
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"bufio"
"os"
"os/exec"
"strings"
)
type PlatformStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
IDLike string `json:"id_like,omitempty"`
PackageManager string `json:"package_manager"`
Desktop string `json:"desktop,omitempty"`
Session string `json:"session,omitempty"`
SystemdUser bool `json:"systemd_user"`
Omarchy bool `json:"omarchy"`
}
func detectPlatform() PlatformStatus {
p := PlatformStatus{ID: "linux", Name: "Linux"}
if f, err := os.Open("/etc/os-release"); err == nil {
defer f.Close()
vals := map[string]string{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if ok {
vals[k] = strings.Trim(strings.TrimSpace(v), `"'`)
}
}
if vals["ID"] != "" {
p.ID = vals["ID"]
}
if vals["PRETTY_NAME"] != "" {
p.Name = vals["PRETTY_NAME"]
} else if vals["NAME"] != "" {
p.Name = vals["NAME"]
}
p.Version = vals["VERSION_ID"]
p.IDLike = vals["ID_LIKE"]
}
switch {
case commandExists("apt-get"):
p.PackageManager = "apt"
case commandExists("dnf"):
p.PackageManager = "dnf"
case commandExists("pacman"):
p.PackageManager = "pacman"
case commandExists("zypper"):
p.PackageManager = "zypper"
case commandExists("apk"):
p.PackageManager = "apk"
default:
p.PackageManager = "unknown"
}
p.Desktop = envOr("XDG_CURRENT_DESKTOP", envOr("DESKTOP_SESSION", ""))
p.Session = envOr("XDG_SESSION_TYPE", "")
if commandExists("systemctl") {
cmd := exec.Command("systemctl", "--user", "show-environment")
p.SystemdUser = cmd.Run() == nil
}
p.Omarchy = commandExists("omarchy")
return p
}
+826
View File
@@ -0,0 +1,826 @@
package main
import (
"bufio"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
const (
winetricksRepo = "Winetricks/winetricks"
rsiBaseURL = "https://install.robertsspaceindustries.com/rel/2"
rsiLatestYML = rsiBaseURL + "/latest.yml"
)
type GameConfig struct {
Prefix string `json:"prefix"`
GameDir string `json:"game_dir"`
LauncherEXE string `json:"launcher_exe"`
RSIInstaller string `json:"rsi_installer,omitempty"`
WinetricksVersion string `json:"winetricks_version,omitempty"`
InstalledAt string `json:"installed_at,omitempty"`
LastRepair string `json:"last_repair,omitempty"`
}
type GameStatus struct {
BackendVersion string `json:"backend_version"`
Health string `json:"health"`
Hardware string `json:"hardware"`
HardwareReason string `json:"hardware_reason,omitempty"`
GPU string `json:"gpu,omitempty"`
Vulkan string `json:"vulkan,omitempty"`
CPUAVX bool `json:"cpu_avx"`
RAMGiB int `json:"ram_gib"`
CombinedGiB int `json:"combined_gib"`
DiskFreeGiB int `json:"disk_free_gib"`
Prefix string `json:"prefix"`
PrefixState string `json:"prefix_state"`
LauncherState string `json:"launcher_state"`
GameState string `json:"game_state"`
WineVersion string `json:"wine_version,omitempty"`
DXVKVersion string `json:"dxvk_version,omitempty"`
LUGVersion string `json:"lug_version,omitempty"`
RSIInstaller string `json:"rsi_installer,omitempty"`
Autopilot bool `json:"autopilot"`
}
func (a *App) gameConfigPath() string { return filepath.Join(a.configDir, "game.json") }
func (a *App) loadGameConfig() GameConfig {
cfg := GameConfig{}
if data, err := os.ReadFile(a.gameConfigPath()); err == nil {
_ = json.Unmarshal(data, &cfg)
}
if cfg.Prefix == "" {
// Adopt an existing LUG prefix if one is configured, otherwise use our stable default.
old := filepath.Join(envOr("XDG_CONFIG_HOME", filepath.Join(a.home, ".config")), "starcitizen-lug", "winedir.conf")
if data, err := os.ReadFile(old); err == nil && strings.TrimSpace(string(data)) != "" {
cfg.Prefix = strings.TrimSpace(string(data))
} else {
cfg.Prefix = filepath.Join(a.home, "Games", "star-citizen")
}
}
if cfg.GameDir == "" {
cfg.GameDir = filepath.Join(cfg.Prefix, "drive_c", "Program Files", "Roberts Space Industries", "StarCitizen")
}
if cfg.LauncherEXE == "" {
cfg.LauncherEXE = filepath.Join(cfg.Prefix, "drive_c", "Program Files", "Roberts Space Industries", "RSI Launcher", "RSI Launcher.exe")
}
return cfg
}
func (a *App) saveGameConfig(cfg GameConfig) error {
if err := os.MkdirAll(a.configDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
tmp := a.gameConfigPath() + ".new"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, a.gameConfigPath())
}
func (a *App) gameStatus() GameStatus {
gc := a.loadGameConfig()
uc := a.loadConfig()
hs := a.hardwareStatus(gc.Prefix)
st := GameStatus{
BackendVersion: appVersion,
Health: "setup",
Hardware: hs.State,
HardwareReason: hs.Reason,
GPU: hs.GPU,
Vulkan: hs.Vulkan,
CPUAVX: hs.AVX,
RAMGiB: hs.RAMGiB,
CombinedGiB: hs.CombinedGiB,
DiskFreeGiB: hs.DiskFreeGiB,
Prefix: gc.Prefix,
PrefixState: "missing",
LauncherState: "missing",
GameState: "missing",
WineVersion: readMetaVersion(filepath.Join(a.vendorDir, "wine", "meta.json")),
DXVKVersion: readMetaVersion(filepath.Join(a.vendorDir, "dxvk", "meta.json")),
LUGVersion: readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json")),
RSIInstaller: gc.RSIInstaller,
Autopilot: uc.AutoMaintain,
}
if hs.State == "blocked" {
st.Health = "hardware-blocked"
return st
}
if prefixInitialized(gc.Prefix) {
st.PrefixState = "ready"
} else if fi, err := os.Stat(gc.Prefix); err == nil && fi.IsDir() {
st.PrefixState = "partial"
}
if _, err := os.Stat(gc.LauncherEXE); err == nil {
st.LauncherState = "ready"
}
if _, err := os.Stat(filepath.Join(gc.GameDir, "LIVE", "Bin64", "StarCitizen.exe")); err == nil {
st.GameState = "ready"
} else if _, err := os.Stat(filepath.Join(gc.GameDir, "LIVE", "Data.p4k")); err == nil {
st.GameState = "partial"
}
switch {
case st.WineVersion == "":
st.Health = "setup"
case st.PrefixState == "partial":
st.Health = "repair"
case st.PrefixState != "ready":
st.Health = "install"
case st.LauncherState != "ready":
st.Health = "launcher-repair"
case st.GameState != "ready":
st.Health = "install-game"
default:
st.Health = "ready"
}
return st
}
type hardwareInfo struct {
State string
Reason string
GPU string
Vulkan string
AVX bool
RAMGiB int
CombinedGiB int
DiskFreeGiB int
}
func (a *App) hardwareStatus(prefix string) hardwareInfo {
h := hardwareInfo{State: "ready", Vulkan: "unknown"}
if runtime.GOARCH != "amd64" {
h.State = "blocked"
h.Reason = "Star Citizen benötigt x86-64."
return h
}
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
low := strings.ToLower(string(data))
h.AVX = regexp.MustCompile(`(?m)^flags\s*:.*\bavx\b`).MatchString(low)
if !h.AVX {
h.State = "blocked"
h.Reason = "Die CPU stellt AVX nicht bereit."
}
}
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
vals := map[string]int64{}
sc := bufio.NewScanner(strings.NewReader(string(data)))
for sc.Scan() {
f := strings.Fields(sc.Text())
if len(f) >= 2 {
n, _ := strconv.ParseInt(f[1], 10, 64)
vals[strings.TrimSuffix(f[0], ":")] = n
}
}
h.RAMGiB = int(vals["MemTotal"] / (1024 * 1024))
h.CombinedGiB = int((vals["MemTotal"] + vals["SwapTotal"]) / (1024 * 1024))
if h.RAMGiB < 14 && h.State != "blocked" {
h.State = "blocked"
h.Reason = "Weniger als 16 GiB RAM erkannt."
}
}
var stat syscallStatfs
if statfs(prefix, &stat) == nil {
h.DiskFreeGiB = int((stat.Bavail * uint64(stat.Bsize)) / (1024 * 1024 * 1024))
} else if statfs(a.home, &stat) == nil {
h.DiskFreeGiB = int((stat.Bavail * uint64(stat.Bsize)) / (1024 * 1024 * 1024))
}
pci, _ := exec.Command("lspci", "-nnk").CombinedOutput()
p := string(pci)
if strings.TrimSpace(p) == "" {
// lspci/pciutils is optional on minimal Debian installations. Detect the
// QEMU VGA device through sysfs as a dependency-free fallback.
vendors, _ := filepath.Glob("/sys/class/drm/card*/device/vendor")
for _, vp := range vendors {
v, _ := os.ReadFile(vp)
d, _ := os.ReadFile(filepath.Join(filepath.Dir(vp), "device"))
if strings.TrimSpace(string(v)) == "0x1234" && strings.TrimSpace(string(d)) == "0x1111" {
p = "QEMU Standard VGA [1234:1111]\nKernel driver in use: bochs-drm"
break
}
}
}
for _, line := range strings.Split(p, "\n") {
low := strings.ToLower(line)
if strings.Contains(low, "vga compatible controller") || strings.Contains(low, "3d controller") || strings.Contains(low, "display controller") {
if h.GPU == "" {
h.GPU = strings.TrimSpace(line)
}
}
}
lowp := strings.ToLower(p)
if strings.Contains(lowp, "1234:1111") || strings.Contains(lowp, "kernel driver in use: bochs") {
h.State = "blocked"
h.Vulkan = "blocked"
h.Reason = "QEMU Standard VGA/bochs-drm erkannt. Star Citizen benötigt eine echte Vulkan-GPU oder GPU-Passthrough."
return h
}
if path, err := exec.LookPath("vulkaninfo"); err == nil {
cmd := exec.Command(path, "--summary")
out, err := cmd.CombinedOutput()
if err != nil || strings.Contains(string(out), "VK_ERROR_INCOMPATIBLE_DRIVER") {
h.State = "blocked"
h.Vulkan = "blocked"
h.Reason = "Vulkan ist nicht funktionsfähig."
return h
}
h.Vulkan = "ready"
} else {
matches, _ := filepath.Glob("/usr/share/vulkan/icd.d/*.json")
if len(matches) > 0 {
h.Vulkan = "probable"
} else {
h.Vulkan = "unknown"
}
}
return h
}
// Small local wrapper lets this file remain platform-specific only where needed.
type syscallStatfs struct {
Type, Bsize int64
Blocks, Bfree, Bavail, Files, Ffree, Fsid0, Fsid1 uint64
Namelen, Frsize, Flags int64
Spare [4]int64
}
func statfs(path string, s *syscallStatfs) error {
// Use df instead of syscall.Statfs to keep the struct portable across Go versions.
out, err := exec.Command("df", "-Pk", path).CombinedOutput()
if err != nil {
return err
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return errors.New("df output")
}
f := strings.Fields(lines[len(lines)-1])
if len(f) < 4 {
return errors.New("df fields")
}
avail, _ := strconv.ParseUint(f[3], 10, 64)
s.Bavail = avail
s.Bsize = 1024
return nil
}
func (a *App) currentRunner() (string, error) {
p, err := filepath.EvalSymlinks(filepath.Join(a.vendorDir, "wine", "current"))
if err != nil {
return "", errors.New("kein kompatibler verwalteter Wine-Runner aktiv")
}
if _, err := os.Stat(filepath.Join(p, "bin", "wine")); err != nil {
return "", err
}
return p, nil
}
func (a *App) runnerEnv(prefix string) ([]string, string, error) {
runner, err := a.currentRunner()
if err != nil {
return nil, "", err
}
bin := filepath.Join(runner, "bin")
env := append([]string{}, os.Environ()...)
env = append(env,
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"WINE="+filepath.Join(bin, "wine"),
"WINESERVER="+filepath.Join(bin, "wineserver"),
"WINEPREFIX="+prefix,
"WINEARCH=win64",
"WINEDEBUG=-all",
"WINEDLLOVERRIDES=dxwebsetup.exe,dotNetFx45_Full_setup.exe,winemenubuilder.exe=d",
)
return env, runner, nil
}
func (a *App) ensureLUGRuntime() (string, error) {
appimage := filepath.Join(a.vendorDir, "lug-helper", "lug-helper.appimage")
ver := readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json"))
if appimage == "" || ver == "" {
return "", errors.New("LUG AppImage runtime ist noch nicht synchronisiert")
}
base := filepath.Join(a.vendorDir, "lug-helper", "runtime", ver)
if _, err := os.Stat(filepath.Join(base, "usr", "bin", "cabextract")); err == nil {
return base, nil
}
if err := os.MkdirAll(filepath.Dir(base), 0o755); err != nil {
return "", err
}
tmp, err := os.MkdirTemp(a.cacheDir, "lug-appimage-")
if err != nil {
return "", err
}
defer os.RemoveAll(tmp)
cmd := exec.Command(appimage, "--appimage-extract")
cmd.Dir = tmp
cmd.Env = append(os.Environ(), "APPIMAGE_EXTRACT_AND_RUN=1")
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("LUG AppImage Runtime konnte nicht entpackt werden: %s", formatCommandFailure(err, out))
}
extracted := filepath.Join(tmp, "squashfs-root")
if _, err := os.Stat(extracted); err != nil {
return "", errors.New("AppImage-Extraktion erzeugte kein squashfs-root")
}
_ = os.RemoveAll(base + ".new")
if err := copyTree(extracted, base+".new"); err != nil {
return "", err
}
_ = os.RemoveAll(base)
if err := os.Rename(base+".new", base); err != nil {
return "", err
}
return base, nil
}
func (a *App) toolsEnv(base []string) ([]string, error) {
// Prefer ordinary distro tools. Debian packages install these explicitly,
// while most desktop distributions already provide curl/unzip.
required := []string{"cabextract", "curl", "unzip"}
allSystem := true
for _, tool := range required {
if _, err := exec.LookPath(tool); err != nil {
allSystem = false
break
}
}
if allSystem {
return append([]string{}, base...), nil
}
// Portable fallback: use only the extracted runtime of the optional LUG
// AppImage as a toolbox. The LUG installer itself is never invoked.
if err := a.syncLUGHelper(); err != nil {
return base, fmt.Errorf("Systemwerkzeuge fehlen (%s) und portable Toolbox konnte nicht geladen werden: %w", strings.Join(required, ", "), err)
}
runtimeRoot, err := a.ensureLUGRuntime()
if err != nil {
return base, err
}
dirs := []string{filepath.Join(runtimeRoot, "usr", "bin"), filepath.Join(runtimeRoot, "bin"), filepath.Join(runtimeRoot, "usr", "sbin")}
libdirs := []string{filepath.Join(runtimeRoot, "usr", "lib"), filepath.Join(runtimeRoot, "usr", "lib64"), filepath.Join(runtimeRoot, "lib"), filepath.Join(runtimeRoot, "lib64")}
env := append([]string{}, base...)
oldPath := os.Getenv("PATH")
env = append(env, "PATH="+strings.Join(dirs, string(os.PathListSeparator))+string(os.PathListSeparator)+oldPath)
existingLD := os.Getenv("LD_LIBRARY_PATH")
ld := strings.Join(libdirs, string(os.PathListSeparator))
if existingLD != "" {
ld += string(os.PathListSeparator) + existingLD
}
env = append(env, "LD_LIBRARY_PATH="+ld)
return env, nil
}
func (a *App) syncWinetricks() (string, string, error) {
rel, err := githubLatest(winetricksRepo)
if err != nil {
return "", "", err
}
tag := rel.TagName
if tag == "" {
return "", "", errors.New("Winetricks-Release ohne Tag")
}
dir := filepath.Join(a.vendorDir, "winetricks", tag)
target := filepath.Join(dir, "winetricks")
if _, err := os.Stat(target); err == nil {
return target, tag, nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", "", err
}
u := "https://raw.githubusercontent.com/Winetricks/winetricks/refs/tags/" + tag + "/src/winetricks"
if err := download(u, target+".new"); err != nil {
return "", "", err
}
if err := os.Chmod(target+".new", 0o755); err != nil {
return "", "", err
}
if err := os.Rename(target+".new", target); err != nil {
return "", "", err
}
return target, tag, nil
}
func (a *App) latestRSIInstaller() (string, string, string, error) {
req, _ := http.NewRequest("GET", rsiLatestYML, nil)
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", "", fmt.Errorf("RSI latest.yml HTTP %d", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", "", "", err
}
reURL := regexp.MustCompile(`(?m)^url:\s*(.+?)\s*$`)
m := reURL.FindSubmatch(b)
if len(m) < 2 {
return "", "", "", errors.New("RSI latest.yml Format unbekannt")
}
file := strings.TrimSpace(string(m[1]))
file = strings.Trim(file, "\"'")
sha := ""
reSHA := regexp.MustCompile(`(?m)^sha512:\s*(.+?)\s*$`)
if sm := reSHA.FindSubmatch(b); len(sm) >= 2 {
sha = strings.TrimSpace(string(sm[1]))
sha = strings.Trim(sha, "\"'")
}
base, _ := neturl.Parse(rsiBaseURL + "/")
ref := &neturl.URL{Path: file}
return file, base.ResolveReference(ref).String(), sha, nil
}
func verifySHA512Base64(path, want string) error {
if strings.TrimSpace(want) == "" {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha512.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
got := base64.StdEncoding.EncodeToString(h.Sum(nil))
if got != strings.TrimSpace(want) {
return fmt.Errorf("SHA-512 mismatch for %s", filepath.Base(path))
}
return nil
}
func (a *App) backupIncompletePrefix(gc GameConfig) (GameConfig, string, error) {
fi, err := os.Stat(gc.Prefix)
if err != nil || !fi.IsDir() {
return gc, "", nil
}
if prefixInitialized(gc.Prefix) {
return gc, "", nil
}
if entries, err := os.ReadDir(gc.Prefix); err == nil && len(entries) == 0 {
return gc, "", nil
}
stamp := time.Now().Format("20060102-150405")
backup := gc.Prefix + "-incomplete-" + stamp
if err := os.Rename(gc.Prefix, backup); err != nil {
return gc, "", err
}
a.logf("backed up incomplete prefix %s -> %s", gc.Prefix, backup)
return gc, backup, nil
}
func (a *App) gameInstall() error {
unlock, err := a.acquireMaintenanceLock()
if err != nil {
return err
}
defer unlock()
gc := a.loadGameConfig()
hs := a.hardwareStatus(gc.Prefix)
if hs.State == "blocked" {
return fmt.Errorf("hardware nicht spielbereit: %s", hs.Reason)
}
// LUG is not part of the installation control path. A portable runtime is
// fetched lazily only when required system tools are missing.
if err := a.syncWineRunner(); err != nil {
return fmt.Errorf("Wine: %w", err)
}
if _, _, err := a.syncWinetricks(); err != nil {
return fmt.Errorf("Winetricks: %w", err)
}
// Adopt a complete existing prefix. Otherwise safely back up partial state.
if !prefixInitialized(gc.Prefix) {
_, backup, err := a.backupIncompletePrefix(gc)
if err != nil {
return err
}
if backup != "" {
a.logf("partial prefix preserved at %s", backup)
}
if err := os.MkdirAll(gc.Prefix, 0o755); err != nil {
return err
}
if err := a.initializePrefix(gc); err != nil {
return err
}
}
if err := a.ensurePrefixComponents(gc); err != nil {
return err
}
if err := a.ensureRSILauncher(gc, true); err != nil {
return err
}
if err := a.writeOwnedLaunchFiles(gc); err != nil {
return err
}
if err := a.writeLUGCompatibilityConfig(gc); err != nil {
a.logf("LUG compatibility config warning: %v", err)
}
if err := a.syncDXVK(); err != nil {
a.logf("DXVK post-install warning: %v", err)
}
gc.RSIInstaller = readText(filepath.Join(a.vendorDir, "rsi", "current.txt"))
gc.WinetricksVersion = readMetaVersion(filepath.Join(a.vendorDir, "winetricks", "meta.json"))
gc.InstalledAt = time.Now().Format(time.RFC3339)
if err := a.saveGameConfig(gc); err != nil {
return err
}
a.logf("game stack installed prefix=%s wine=%s", gc.Prefix, readMetaVersion(filepath.Join(a.vendorDir, "wine", "meta.json")))
return nil
}
func (a *App) initializePrefix(gc GameConfig) error {
env, runner, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
}
env, err = a.toolsEnv(env)
if err != nil {
return err
}
bin := filepath.Join(runner, "bin")
cmd := exec.Command(filepath.Join(bin, "wineboot"), "-u")
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("Wine-Prefix konnte nicht erstellt werden: %s", formatCommandFailure(err, out))
}
wait := exec.Command(filepath.Join(bin, "wineserver"), "-w")
wait.Env = env
_, _ = wait.CombinedOutput()
deadline := time.Now().Add(30 * time.Second)
for !prefixInitialized(gc.Prefix) && time.Now().Before(deadline) {
time.Sleep(300 * time.Millisecond)
}
if !prefixInitialized(gc.Prefix) {
return errors.New("wineboot meldete Erfolg, aber drive_c/system.reg/user.reg fehlen")
}
return nil
}
func (a *App) ensurePrefixComponents(gc GameConfig) error {
wt, tag, err := a.syncWinetricks()
if err != nil {
return err
}
env, _, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
}
env, err = a.toolsEnv(env)
if err != nil {
return err
}
cache := filepath.Join(a.cacheDir, "winetricks-cache", tag)
_ = os.MkdirAll(cache, 0o755)
env = append(env, "W_CACHE="+cache, "WINETRICKS_DOWNLOADER=curl")
cmd := exec.Command(wt, "-q", "arial", "tahoma", "powershell", "win11")
cmd.Env = env
out, err := cmd.CombinedOutput()
a.logf("winetricks %s output=%s", tag, compactDiagnostic(string(out)))
if err != nil {
return fmt.Errorf("Prefix-Komponenten: %s", formatCommandFailure(err, out))
}
runner, _ := a.currentRunner()
wine := filepath.Join(runner, "bin", "wine")
reg := exec.Command(wine, "reg", "add", `HKEY_CURRENT_USER\Software\Wine\FileOpenAssociations`, `/v`, `Enable`, `/d`, `N`, `/f`)
reg.Env = env
out, err = reg.CombinedOutput()
if err != nil {
return fmt.Errorf("Wine Registry: %s", formatCommandFailure(err, out))
}
_ = writeMeta(filepath.Join(a.vendorDir, "winetricks", "meta.json"), componentMeta{Version: tag, Updated: time.Now().Format(time.RFC3339)})
return nil
}
func (a *App) ensureRSILauncher(gc GameConfig, forceIfMissing bool) error {
file, u, sha, err := a.latestRSIInstaller()
if err != nil {
return err
}
metaDir := filepath.Join(a.vendorDir, "rsi")
if err := os.MkdirAll(metaDir, 0o755); err != nil {
return err
}
old := readText(filepath.Join(metaDir, "current.txt"))
_, launcherErr := os.Stat(gc.LauncherEXE)
if launcherErr == nil && old == file && !forceIfMissing {
return nil
}
if launcherErr == nil && old == file {
return nil
}
cache := filepath.Join(a.cacheDir, file)
if _, err := os.Stat(cache); err != nil {
if err := download(u, cache); err != nil {
return err
}
}
if err := verifySHA512Base64(cache, sha); err != nil {
_ = os.Remove(cache)
return err
}
env, runner, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
}
wine := filepath.Join(runner, "bin", "wine")
cmd := exec.Command(wine, cache, "/S")
cmd.Env = env
out, err := cmd.CombinedOutput()
a.logf("RSI installer %s output=%s", file, compactDiagnostic(string(out)))
if err != nil {
return fmt.Errorf("RSI Launcher Installation: %s", formatCommandFailure(err, out))
}
ws := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-k")
ws.Env = env
_, _ = ws.CombinedOutput()
deadline := time.Now().Add(20 * time.Second)
for {
if _, err := os.Stat(gc.LauncherEXE); err == nil {
break
}
if time.Now().After(deadline) {
return fmt.Errorf("RSI Installer beendet, Launcher fehlt: %s", gc.LauncherEXE)
}
time.Sleep(500 * time.Millisecond)
}
if err := os.WriteFile(filepath.Join(metaDir, "current.txt"), []byte(file+"\n"), 0o644); err != nil {
return err
}
return nil
}
func (a *App) writeOwnedLaunchFiles(gc GameConfig) error {
binDir := filepath.Join(a.dataDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
return err
}
script := filepath.Join(binDir, "star-citizen-launch")
backend := filepath.Join(a.libDir, "citizen-launcher")
content := "#!/usr/bin/env bash\nexec " + shellQuote(backend) + " game-launch\n"
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
return err
}
apps := filepath.Join(envOr("XDG_DATA_HOME", filepath.Join(a.home, ".local", "share")), "applications")
_ = os.MkdirAll(apps, 0o755)
desktop := `[Desktop Entry]
Name=Star Citizen
Comment=Star Citizen via Citizen Launcher
Type=Application
Categories=Game;
Terminal=false
StartupNotify=true
StartupWMClass=rsi launcher.exe
Exec=` + script + `\n`
if err := os.WriteFile(filepath.Join(apps, "citizen-launcher-star-citizen.desktop"), []byte(desktop), 0o644); err != nil {
return err
}
return nil
}
func (a *App) writeLUGCompatibilityConfig(gc GameConfig) error {
dir := filepath.Join(envOr("XDG_CONFIG_HOME", filepath.Join(a.home, ".config")), "starcitizen-lug")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "winedir.conf"), []byte(gc.Prefix+"\n"), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "gamedir.conf"), []byte(gc.GameDir+"\n"), 0o644); err != nil {
return err
}
return nil
}
func (a *App) gameLaunch() error {
gc := a.loadGameConfig()
st := a.gameStatus()
// On non-systemd distributions (or a stopped user timer), maintenance still
// happens opportunistically when the player launches the game. Never block
// the launch on network/update work.
if cfg := a.loadConfig(); cfg.AutoMaintain {
stale := true
if t, err := time.Parse(time.RFC3339, cfg.LastMaintenance); err == nil {
stale = time.Since(t) > 6*time.Hour
}
if stale && a.selfPath != "" {
_ = exec.Command(a.selfPath, "maintain").Start()
}
}
if st.Hardware == "blocked" {
return errors.New(st.HardwareReason)
}
if !prefixInitialized(gc.Prefix) {
return errors.New("Wine-Prefix fehlt; zuerst Setup ausführen")
}
if _, err := os.Stat(gc.LauncherEXE); err != nil {
return errors.New("RSI Launcher fehlt; Reparatur ausführen")
}
env, runner, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
}
env = append(env,
"__GL_SHADER_DISK_CACHE=1",
"__GL_SHADER_DISK_CACHE_SIZE=10737418240",
"__GL_SHADER_DISK_CACHE_PATH="+gc.Prefix,
"MESA_SHADER_CACHE_DIR="+gc.Prefix,
"MESA_SHADER_CACHE_MAX_SIZE=10G",
)
log := filepath.Join(a.stateDir, "rsi-launcher.log")
_ = os.MkdirAll(a.stateDir, 0o755)
f, err := os.OpenFile(log, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
cmd := exec.Command(filepath.Join(runner, "bin", "wine"), gc.LauncherEXE)
cmd.Env = env
cmd.Stdout = f
cmd.Stderr = f
if err := cmd.Start(); err != nil {
f.Close()
return err
}
_ = f.Close()
a.logf("RSI Launcher started pid=%d", cmd.Process.Pid)
return nil
}
func (a *App) gameRepair() error {
gc := a.loadGameConfig()
hs := a.hardwareStatus(gc.Prefix)
if hs.State == "blocked" {
return errors.New(hs.Reason)
}
if err := a.syncLUGHelper(); err != nil {
a.logf("LUG runtime warning: %v", err)
}
if err := a.syncWineRunner(); err != nil {
return err
}
if !prefixInitialized(gc.Prefix) {
return a.gameInstall()
}
if err := a.ensurePrefixComponents(gc); err != nil {
return err
}
if err := a.ensureRSILauncher(gc, false); err != nil {
return err
}
if err := a.writeOwnedLaunchFiles(gc); err != nil {
return err
}
gc.LastRepair = time.Now().Format(time.RFC3339)
_ = a.saveGameConfig(gc)
return nil
}
func (a *App) syncRSILauncherIfNeeded() error {
gc := a.loadGameConfig()
if !prefixInitialized(gc.Prefix) || prefixBusy(gc.Prefix) {
return nil
}
return a.ensureRSILauncher(gc, false)
}
func readText(path string) string { b, _ := os.ReadFile(path); return strings.TrimSpace(string(b)) }
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" }
// Keep sort referenced here because installation diagnostics intentionally sort paths in future additions.
var _ = sort.Strings
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"regexp"
"strings"
"time"
)
func (a *App) createSupportBundle() (string, error) {
stamp := time.Now().Format("20060102-150405")
id := fmt.Sprintf("CL-%s-%d", stamp, os.Getpid())
downloads := filepath.Join(a.home, "Downloads")
if out, err := exec.Command("xdg-user-dir", "DOWNLOAD").Output(); err == nil && strings.TrimSpace(string(out)) != "" {
downloads = strings.TrimSpace(string(out))
}
if err := os.MkdirAll(downloads, 0o755); err != nil {
return "", err
}
target := filepath.Join(downloads, "Citizen-Launcher-Support-"+id+".tar.gz")
f, err := os.Create(target)
if err != nil {
return "", err
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
closeAll := func() { _ = tw.Close(); _ = gz.Close(); _ = f.Close() }
defer closeAll()
add := func(name string, data []byte) error {
data = sanitizeSupport(data, a.home)
h := &tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: time.Now()}
if err := tw.WriteHeader(h); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
addJSON := func(name string, v any) {
b, _ := json.MarshalIndent(v, "", " ")
b = append(b, '\n')
_ = add(name, b)
}
addJSON("platform.json", detectPlatform())
addJSON("game-status.json", a.gameStatus())
if st, _ := a.status(false); true {
addJSON("launcher-status.json", st)
}
addJSON("game-config.json", a.loadGameConfig())
_ = add("README.txt", []byte("Citizen Launcher support bundle\nID: "+id+"\nPersonal paths, email/IP/MAC and common tokens are redacted on a best-effort basis.\n"))
for name, cmd := range map[string][]string{
"uname.txt": {"uname", "-a"},
"os-release.txt": {"cat", "/etc/os-release"},
"memory.txt": {"free", "-h"},
"disk.txt": {"df", "-hT"},
"pci.txt": {"lspci", "-nnk"},
"vulkan.txt": {"vulkaninfo", "--summary"},
"systemd-timer.txt": {"systemctl", "--user", "status", "citizen-launcher-maintenance.timer", "--no-pager"},
} {
if _, err := exec.LookPath(cmd[0]); err != nil {
continue
}
out, _ := exec.Command(cmd[0], cmd[1:]...).CombinedOutput()
_ = add(name, out)
}
for name, path := range map[string]string{
"launcher.log": a.logPath,
"rsi-launcher.log": filepath.Join(a.stateDir, "rsi-launcher.log"),
} {
if b, err := tailFile(path, 700000); err == nil {
_ = add(name, b)
}
}
return target, nil
}
func tailFile(path string, max int64) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return nil, err
}
off := int64(0)
if st.Size() > max {
off = st.Size() - max
}
if _, err = f.Seek(off, 0); err != nil {
return nil, err
}
var b bytes.Buffer
_, err = b.ReadFrom(f)
return b.Bytes(), err
}
func sanitizeSupport(in []byte, home string) []byte {
s := string(in)
if home != "" {
s = strings.ReplaceAll(s, home, "~")
}
if u, err := user.Current(); err == nil && u.Username != "" {
s = regexp.MustCompile(`(?i)\\b`+regexp.QuoteMeta(u.Username)+`\\b`).ReplaceAllString(s, "<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>"},
}
for _, p := range patterns {
s = regexp.MustCompile(p.re).ReplaceAllString(s, p.repl)
}
return []byte(s)
}
+1
View File
@@ -0,0 +1 @@
const B=window.CITIZEN_BASE;let current=null;const $=id=>document.getElementById(id);async function api(path,opts){const r=await fetch(B+path,opts);if(!r.ok)throw new Error(await r.text());return r.json()}function cls(el,state){el.classList.remove('good','bad','warn');if(state)el.classList.add(state)}function txt(id,v,c){const e=$(id);e.textContent=v||'—';cls(e,c)}function primaryFor(h){if(h==='ready')return['STAR CITIZEN STARTEN','launch'];if(h==='hardware-blocked')return['HARDWARE-DETAILS','doctor'];if(h==='repair'||h==='launcher-repair')return['AUTOMATISCH REPARIEREN','repair'];return['EINRICHTEN & STARTKLAR MACHEN','setup']}async function refresh(){try{const s=await api('/api/status');current=s;$('version').textContent='v'+s.version;$('platform').textContent=[s.platform.name,s.platform.desktop].filter(Boolean).join(' · ');$('distro').textContent=s.platform.name;$('session').textContent=s.platform.session||'—';const g=s.game;let head='READY FOR SETUP',sub='Citizen Launcher übernimmt Wine, DXVK, Prefix und RSI Launcher.';if(g.health==='ready'){head='FLIGHT READY';sub='Star Citizen ist startbereit. Autopilot hält den Gaming-Stack aktuell.'}else if(g.health==='hardware-blocked'){head='HARDWARE CHECK';sub=g.hardware_reason||'Die Hardware erfüllt eine Voraussetzung nicht.'}else if(g.health==='repair'||g.health==='launcher-repair'){head='REPAIR AVAILABLE';sub='Ein reparierbarer Zustand wurde erkannt.'}$('headline').textContent=head;$('subline').textContent=sub;const [label,act]=primaryFor(g.health);$('primary').textContent=label;$('primary').dataset.action=act;txt('gpu',g.gpu||'nicht eindeutig erkannt',g.hardware==='blocked'?'bad':'good');txt('vulkan',g.vulkan,g.vulkan==='blocked'?'bad':g.vulkan==='ready'?'good':'warn');txt('ram',g.ram_gib+' GiB',g.ram_gib>=16?'good':'warn');txt('wine',g.wine_version||'wird eingerichtet',g.wine_version?'good':'warn');txt('dxvk',g.dxvk_version||'wird eingerichtet',g.dxvk_version?'good':'warn');txt('rsi',g.launcher_state==='ready'?'bereit':'Setup',g.launcher_state==='ready'?'good':'warn');txt('autopilot',g.autopilot?'aktiv':'aus',g.autopilot?'good':'warn');$('raw').textContent=JSON.stringify(s,null,2)}catch(e){$('headline').textContent='BACKEND ERROR';$('subline').textContent=e.message}}async function action(name){try{if(name==='launch'){await api('/api/action/launch',{method:'POST'});return}const j=await api('/api/action/'+name,{method:'POST'});$('job').classList.remove('hidden');$('jobTitle').textContent='Aktion: '+name;$('jobText').textContent='läuft …';poll(j.id)}catch(e){$('job').classList.remove('hidden');$('jobTitle').textContent='Fehler';$('jobText').textContent=e.message}}async function poll(id){try{const j=await api('/api/job/'+id);if(j.state==='running'){setTimeout(()=>poll(id),1000);return}$('jobText').textContent=j.state==='done'?(j.message||'Abgeschlossen'):j.error;$('job').querySelector('.spinner').classList.toggle('hidden',j.state!=='running');await refresh()}catch(e){$('jobText').textContent=e.message}}document.addEventListener('click',e=>{const a=e.target.dataset.action;if(a)action(a);const o=e.target.dataset.open;if(o)api('/api/open/'+o,{method:'POST'});});$('toggle').onclick=()=>$('advanced').classList.toggle('hidden');$('primary').onclick=()=>action($('primary').dataset.action);refresh();setInterval(refresh,10000);
@@ -0,0 +1,16 @@
<!doctype html>
<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><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>
<section class="hero" id="hero"><div><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="grid three">
<article><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><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><h3>Autopilot</h3><div class="metric"><span>Wartung</span><b id="autopilot">—</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">Gaming-Stack aktualisieren</button><button data-action="repair">Problem automatisch beheben</button><button data-action="doctor">Wine-Selbsttest</button><button data-action="support">Support-Paket erstellen</button></section>
<section class="job hidden" id="job"><div class="spinner"></div><div><b id="jobTitle">Aktion läuft</b><p id="jobText"></p></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 Star Citizen im Benutzerkonto. System-/GPU-Treiber bleiben Aufgabe der Distribution.</footer>
</main><script>window.CITIZEN_BASE="__BASE__";</script><script src="__BASE__/assets/app.js"></script></body></html>
@@ -0,0 +1 @@
:root{color-scheme:dark;--bg:#070d13;--panel:#0b1822;--panel2:#0f2230;--line:#1d526d;--cyan:#55d8ff;--green:#75e9b1;--amber:#ffd275;--red:#ff879c;--text:#e8f5fb;--muted:#8da7b5}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 70% -20%,#123047 0,transparent 42%),linear-gradient(150deg,#050a10,#09131c 60%,#06101a);color:var(--text);font:15px Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}.scan{position:fixed;inset:0;pointer-events:none;opacity:.12;background:repeating-linear-gradient(0deg,transparent 0 3px,#66dbff10 4px)}main{width:min(1180px,calc(100% - 32px));margin:28px auto 48px}header{display:flex;align-items:center;gap:16px;padding:0 4px 20px}.mark{width:54px;height:54px;display:grid;place-items:center;border:1px solid var(--cyan);border-radius:14px;color:var(--cyan);font-size:25px;box-shadow:0 0 25px #4bd5ff22 inset}.eyebrow{font-size:11px;letter-spacing:.18em;color:var(--cyan);font-weight:800}h1,h2,h3,p{margin:0}h1{font-size:25px;letter-spacing:.02em}header p{color:var(--muted);margin-top:3px}.version{margin-left:auto;color:var(--muted);font-family:ui-monospace,monospace}.hero,.grid article,.job,.details{border:1px solid #1c4053;border-radius:20px;background:linear-gradient(155deg,#0c1b26e8,#09131be8);box-shadow:0 18px 60px #0007}.hero{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:28px;border-color:#2c7798}.hero h2{font-size:35px;letter-spacing:.04em;margin:5px 0 7px}.hero p{color:var(--muted);max-width:690px}.primary{padding:17px 28px!important;font-weight:900;letter-spacing:.08em;border-color:var(--cyan)!important;color:#08141c!important;background:linear-gradient(135deg,#7de4ff,#45c8f7)!important;min-width:270px}.grid{display:grid;gap:14px;margin-top:14px}.three{grid-template-columns:repeat(3,1fr)}article{padding:20px}article h3{color:var(--cyan);font-size:13px;letter-spacing:.12em;text-transform:uppercase;margin-bottom:12px}.metric{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #153345}.metric:last-child{border:0}.metric span{color:var(--muted)}.metric b{text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:65%}.actions{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:14px 0}button{border:1px solid #2d5368;background:#0d1b25;color:var(--text);padding:13px 16px;border-radius:10px;cursor:pointer;font:inherit;transition:.15s}button:hover{border-color:var(--cyan);background:#102737}.job{display:flex;align-items:center;gap:14px;padding:16px;margin:14px 0}.job p{color:var(--muted);margin-top:3px}.spinner{width:22px;height:22px;border:2px solid #285165;border-top-color:var(--cyan);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.details{padding:12px 14px}.details>button{width:100%;background:transparent;border:0;color:var(--muted)}pre{white-space:pre-wrap;color:#a9c7d4;background:#061018;padding:14px;border-radius:12px;max-height:330px;overflow:auto;font-size:12px}.small-actions{display:flex;gap:8px}.hidden{display:none!important}.bad{color:var(--red)!important}.warn{color:var(--amber)!important}.good{color:var(--green)!important}footer{text-align:center;color:#597584;font-size:12px;padding:22px}@media(max-width:850px){.three,.actions{grid-template-columns:1fr}.hero{align-items:stretch;flex-direction:column}.primary{width:100%}}
+1 -1
View File
@@ -1,3 +1,3 @@
module omarchy-citizen/backend
module citizen-launcher/backend
go 1.23
+2 -2
View File
@@ -2,7 +2,7 @@
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "$0")" && pwd)"
BACK="$ROOT/bin/omarchy-citizen-backend"
BACK="$ROOT/bin/citizen-launcher"
TEST="$(mktemp -d -t omarchy-citizen-updater.XXXXXX)"
trap 'rm -rf "$TEST"' EXIT
@@ -59,7 +59,7 @@ git -C "$TEST/publisher" push -q
git -C "$HOME/.config/omarchy/plugins/local.omarchy-citizen" remote set-url origin "$TEST/changed.git"
set +e
CHECK_OUTPUT="$("$HOME/.local/lib/omarchy-citizen/omarchy-citizen-backend" check 2>&1)"
CHECK_OUTPUT="$("$HOME/.local/lib/citizen-launcher/citizen-launcher" check 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
(cd "$ROOT/backend" && ./build.sh)
echo "Build fertig: backend/bin/citizen-launcher"
Binary file not shown.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
"$ROOT/install.sh"
command -v omarchy >/dev/null 2>&1 || { echo "Omarchy nicht gefunden; Standalone-Launcher ist trotzdem installiert."; exit 0; }
DEST="$HOME/.config/omarchy/plugins/local.omarchy-citizen"
mkdir -p "$DEST/backend/bin"
cp -f "$ROOT/integrations/omarchy/manifest.json" "$DEST/manifest.json"
cp -f "$ROOT/integrations/omarchy/BarWidget.qml" "$DEST/BarWidget.qml"
cp -f "$ROOT/integrations/omarchy/Panel.qml" "$DEST/Panel.qml"
cp -f "$ROOT/integrations/omarchy/citizenctl" "$DEST/citizenctl"
cp -f "$ROOT/integrations/omarchy/support-sanitize.py" "$DEST/support-sanitize.py"
cp -f "$ROOT/backend/bin/citizen-launcher" "$DEST/backend/bin/citizen-launcher"
chmod +x "$DEST/citizenctl" "$DEST/backend/bin/citizen-launcher"
omarchy plugin validate "$DEST"
omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true
omarchy plugin enable local.omarchy-citizen >/dev/null 2>&1 || true
omarchy bar move local.omarchy-citizen --section right >/dev/null 2>&1 || true
omarchy restart shell >/dev/null 2>&1 || true
echo "Citizen Launcher + Omarchy Integration installiert."
+23 -57
View File
@@ -1,60 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
BIN_SRC="$ROOT/backend/bin/citizen-launcher"
BIN_DIR="$HOME/.local/bin"
LIB_DIR="$HOME/.local/lib/citizen-launcher"
APP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
PLUGIN_ID="local.omarchy-citizen"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
DEST="$HOME/.config/omarchy/plugins/$PLUGIN_ID"
if ! command -v omarchy >/dev/null 2>&1; then
echo "Fehler: 'omarchy' wurde nicht gefunden."
echo "Dieses Plugin benötigt Omarchy Quattro mit dem Plugin-System."
exit 1
fi
mkdir -p "$DEST"
for file in manifest.json BarWidget.qml Panel.qml citizenctl support-sanitize.py README.md INSTALLATION.md LICENSE uninstall.sh install.sh INSTALLIEREN.sh; do
src="$SCRIPT_DIR/$file"
dst="$DEST/$file"
if [[ "$(readlink -f "$src")" != "$(readlink -f "$dst" 2>/dev/null || printf '%s' "$dst")" ]]; then
cp -f -- "$src" "$dst"
fi
done
if [[ "$(readlink -f "$SCRIPT_DIR/backend")" != "$(readlink -f "$DEST/backend" 2>/dev/null || printf '%s' "$DEST/backend")" ]]; then
rm -rf -- "$DEST/backend"
cp -a -- "$SCRIPT_DIR/backend" "$DEST/backend"
fi
chmod +x "$DEST/citizenctl" "$DEST/support-sanitize.py" "$DEST/uninstall.sh" "$DEST/INSTALLIEREN.sh" "$DEST/backend/bin/omarchy-citizen-backend"
echo "Validiere Plugin..."
omarchy plugin validate "$DEST"
echo "Lade Plugin neu..."
omarchy-shell shell rescanPlugins
echo "Aktiviere Plugin..."
"$DEST/backend/bin/omarchy-citizen-backend" self-sync >/dev/null 2>&1 || true
echo "Aktiviere Rundum-Sorglos Autopilot …"
"$HOME/.local/lib/omarchy-citizen/omarchy-citizen-backend" autopilot enable || {
echo "Hinweis: Autopilot konnte beim Installer noch nicht vollständig synchronisieren."
echo "Der SC-Setup-Button versucht es erneut und protokolliert die Ursache."
}
omarchy plugin enable "$PLUGIN_ID"
# QML hot-reload is currently unreliable for third-party bar plugins.
omarchy restart shell >/dev/null 2>&1 || true
echo
echo "Omarchy Citizen 0.8.1 wurde installiert:"
echo " $DEST"
echo
echo "Links-Klick auf 'SC' in der Omarchy-Leiste öffnet das Star-Citizen-Panel."
echo
echo "Falls 'SC' nicht sichtbar ist:"
echo " omarchy bar move $PLUGIN_ID --section right"
echo
echo "Omarchy Citizen Status:"
bash "$DEST/citizenctl" status
[[ -x "$BIN_SRC" ]] || { echo "Backend fehlt. Zuerst ./build.sh ausführen." >&2; exit 1; }
mkdir -p "$BIN_DIR" "$LIB_DIR" "$APP_DIR"
install -m755 "$BIN_SRC" "$BIN_DIR/citizen-launcher"
install -m755 "$BIN_SRC" "$LIB_DIR/citizen-launcher"
cat > "$APP_DIR/io.github.citizenlauncher.CitizenLauncher.desktop" <<EOF
[Desktop Entry]
Name=Citizen Launcher
Comment=Star Citizen für Linux – Setup, Start, Updates und Reparatur
Exec=$BIN_DIR/citizen-launcher gui
Terminal=false
Type=Application
Categories=Game;
StartupNotify=true
Keywords=Star Citizen;RSI;Wine;Gaming;
EOF
"$BIN_DIR/citizen-launcher" autopilot enable || true
if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "$APP_DIR" >/dev/null 2>&1 || true; fi
echo "Citizen Launcher installiert. Starte ihn über das App-Menü oder: citizen-launcher gui"
+69
View File
@@ -0,0 +1,69 @@
import QtQuick
import Quickshell
import qs.Ui
BarWidget {
id: root
moduleName: "local.omarchy-citizen"
readonly property bool opened: panelLoader.item
? panelLoader.item.opened === true
: false
readonly property bool popoutSwitchClosing: panelLoader.item
? panelLoader.item.popoutSwitchClosing === true
: false
function open() {
if (panelLoader.item) panelLoader.item.open()
}
function close() {
if (panelLoader.item) panelLoader.item.close()
}
function toggle() {
if (panelLoader.item) panelLoader.item.toggle()
}
function closeForPopoutSwitch() {
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
}
function injectPanel() {
if (!panelLoader.item) return
panelLoader.item.bar = root.bar
panelLoader.item.anchorItem = button
panelLoader.item.hostWidget = root
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onBarChanged: injectPanel()
Loader {
id: panelLoader
active: true
source: Qt.resolvedUrl("Panel.qml")
visible: false
onLoaded: {
root.injectPanel()
Qt.callLater(root.injectPanel)
}
}
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: "✦SC"
tooltipText: "Omarchy Citizen – Plug & Play Star Citizen"
onPressed: function(buttonCode) {
if (buttonCode === Qt.LeftButton) root.toggle()
else if (buttonCode === Qt.RightButton && root.bar)
root.bar.run("xdg-open https://wiki.starcitizen-lug.org")
}
}
}
+972
View File
@@ -0,0 +1,972 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
Panel {
id: root
moduleName: "local.omarchy-citizen"
manageIpc: false
property var anchorItem: null
property var hostWidget: null
property string pluginVersion: "0.9.0"
property string health: "checking"
property string depsState: "checking"
property string depsMissing: ""
property string helperState: "checking"
property string helperManaged: "missing"
property string helperVersion: ""
property string updateState: "unknown"
property string updateVersion: ""
property string prefixState: "checking"
property string prefixDetail: ""
property string launcherState: "checking"
property string gameState: "checking"
property bool checkingUpdate: false
property bool advancedVisible: false
property string backendState: "missing"
property string backendVersion: ""
property string pluginManaged: "manual"
property string pluginAuto: "false"
property string pluginUpdate: "unavailable"
property string pluginLocalCommit: ""
property string pluginRemoteCommit: ""
property string autopilot: "false"
property string managedLUGVersion: ""
property string managedWineVersion: ""
property string managedDXVKVersion: ""
property string maintenanceResult: ""
property string hardwareState: "unknown"
property string hardwareReason: ""
property string hardwareGPU: ""
property string hardwareVulkan: "unknown"
property string actionStatus: ""
property string actionError: ""
property string actionKind: ""
property bool actionCloseOnSuccess: true
readonly property color accent: "#56d2ff"
readonly property color accentSoft: "#1c3444"
readonly property color surface: "#0d1822"
readonly property color surfaceRaised: "#122230"
readonly property color border: "#2b485c"
readonly property color success: "#71e7ad"
readonly property color warning: "#ffd276"
readonly property color danger: "#ff8fa1"
readonly property color muted: "#a8bbc9"
readonly property string uiFont: "sans-serif"
readonly property string controlScript: decodeURIComponent(
Qt.resolvedUrl("citizenctl").toString().replace(/^file:\/\//, "")
)
readonly property bool canLaunch: health === "ready"
function open() { root.controller.show() }
function close() { root.controller.hide() }
function switchPanel(direction) {
if (root.bar && typeof root.bar.switchPanelFrom === "function")
return root.bar.switchPanelFrom(root.hostWidget || root, direction)
return false
}
function runAction(action, closePanel) {
if (actionProc.running) return
root.actionKind = String(action || "")
root.actionCloseOnSuccess = closePanel !== false
root.actionError = ""
root.actionStatus = "Aktion wird gestartet…"
actionProc.command = ["bash", root.controlScript, root.actionKind]
actionProc.running = true
}
function refreshStatus() {
if (!statusProc.running) statusProc.running = true
}
function checkForUpdates() {
if (updateProc.running) return
root.checkingUpdate = true
root.updateState = "checking"
updateProc.running = true
}
function applyStatus(raw) {
var values = {}
var lines = String(raw || "").trim().split("\n")
for (var i = 0; i < lines.length; i++) {
var p = lines[i].indexOf("=")
if (p <= 0) continue
values[lines[i].slice(0, p)] = lines[i].slice(p + 1)
}
pluginVersion = values.plugin_version || "0.9.0"
health = values.health || "setup"
depsState = values.deps || "missing"
depsMissing = values.deps_missing || ""
helperState = values.helper || "missing"
helperManaged = values.helper_managed || "missing"
helperVersion = values.helper_version || ""
updateState = values.update || "unknown"
updateVersion = values.update_version || ""
prefixState = values.prefix || "missing"
prefixDetail = values.prefix_detail || ""
launcherState = values.launcher || "missing"
gameState = values.game || "missing"
backendState = values.backend || "missing"
backendVersion = values.backend_version || ""
pluginManaged = values.plugin_managed || "manual"
pluginAuto = values.plugin_auto || "false"
pluginUpdate = values.plugin_update || "unavailable"
pluginLocalCommit = values.plugin_local_commit || ""
pluginRemoteCommit = values.plugin_remote_commit || ""
autopilot = values.autopilot || "false"
managedLUGVersion = values.managed_lug_version || ""
managedWineVersion = values.managed_wine_version || ""
managedDXVKVersion = values.managed_dxvk_version || ""
maintenanceResult = values.maintenance_result || ""
hardwareState = values.hardware || "unknown"
hardwareReason = values.hardware_reason || ""
hardwareGPU = values.hardware_gpu || ""
hardwareVulkan = values.hardware_vulkan || "unknown"
}
function stateIcon(state) {
if (state === "ready" || state === "current") return "✓"
if (state === "available") return "↑"
if (state === "warning") return "!"
if (state === "partial" || state === "unknown") return "…"
if (state === "checking") return "·"
return "!"
}
function stateColor(state) {
if (state === "ready" || state === "current") return success
if (state === "available" || state === "warning") return warning
if (state === "partial" || state === "unknown" || state === "checking") return accent
return danger
}
function pluginUpdateText() {
if (root.pluginUpdate === "available") return "Update verfügbar"
if (root.pluginUpdate === "current") return "aktuell"
if (root.pluginUpdate === "blocked-dirty") return "lokale Änderungen · Update pausiert"
if (root.pluginUpdate === "blocked-diverged") return "Git-Stand abweichend · Update pausiert"
if (root.pluginUpdate === "blocked-remote") return "Updatequelle geändert · Update pausiert"
if (root.pluginUpdate === "zip") return "ZIP-Installation"
return root.pluginUpdate
}
function pluginUpdateState() {
if (root.pluginUpdate === "available") return "available"
if (root.pluginUpdate === "current") return "current"
if (String(root.pluginUpdate).indexOf("blocked-") === 0) return "warning"
return "unknown"
}
function healthColor() {
if (health === "hardware-blocked") return danger
if (health === "ready") return success
if (health === "recover-prefix" || health === "repair") return warning
if (health === "checking") return accent
return accent
}
function headline() {
if (health === "hardware-blocked") return "HARDWARE NICHT SPIELBEREIT"
if (health === "ready" && root.autopilot === "true") return "FLIGHT READY · AUTOPILOT"
if (health === "ready") return "FLIGHT READY"
if (health === "recover-prefix") return "SETUP REPAIR"
if (health === "repair") return "REPAIR AVAILABLE"
if (health === "checking") return "SYSTEM CHECK"
return "READY FOR SETUP"
}
function subline() {
if (health === "hardware-blocked")
return root.hardwareReason !== "" ? root.hardwareReason : "Eine für Star Citizen geeignete Vulkan-GPU wurde nicht erkannt."
if (health === "ready")
return "Wine, DXVK und RSI Launcher sind geprüft. Star Citizen ist einen Klick entfernt."
if (health === "install-game")
return "Der Software-Stack ist bereit. Melde dich im offiziellen RSI Launcher an und starte dort den Spieldownload."
if (health === "repair" || health === "launcher-repair")
return "Omarchy Citizen kann den gesamten verwalteten Gaming-Stack automatisch reparieren."
return "Omarchy Citizen wählt selbst einen kompatiblen Wine-Runner, erstellt den Prefix, installiert DXVK/Komponenten und den offiziellen RSI Launcher."
}
function primaryText() {
if (health === "hardware-blocked") return "ⓘ HARDWARE-DETAILS"
if (health === "ready") return "▶ STAR CITIZEN STARTEN"
if (health === "install-game") return "▶ RSI LAUNCHER ÖFFNEN"
if (health === "repair" || health === "launcher-repair") return "↻ AUTOMATISCH REPARIEREN"
return "▶ EINRICHTEN & STARTKLAR MACHEN"
}
function updateText() {
if (checkingUpdate || updateState === "checking") return "wird geprüft"
if (helperManaged === "external") return "extern"
if (helperState !== "ready") return "nach Setup"
if (updateState === "available")
return updateVersion !== "" ? "Update " + updateVersion : "Update verfügbar"
if (updateState === "current") return "aktuell"
return "noch nicht geprüft"
}
onOpenedChanged: if (opened) refreshStatus()
Process {
id: statusProc
command: ["bash", root.controlScript, "status"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.applyStatus(text)
}
}
Process {
id: updateProc
command: ["bash", root.controlScript, "check-update"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
root.checkingUpdate = false
root.refreshStatus()
}
}
}
Process {
id: actionProc
running: false
command: []
stdout: StdioCollector {
id: actionOut
waitForEnd: true
}
stderr: StdioCollector {
id: actionErr
waitForEnd: true
}
onStarted: {
if (root.actionKind === "primary" && root.health === "recover-prefix")
root.actionStatus = "Reparatur-Fenster wird geöffnet…"
else if (root.actionKind === "primary")
root.actionStatus = "Setup/Start wird vorbereitet…"
else
root.actionStatus = "Aktion wird ausgeführt…"
}
onExited: function(exitCode) {
if (exitCode === 0) {
root.actionError = ""
root.actionStatus = root.actionKind === "primary" && root.health === "install-game"
? "RSI Launcher wurde gestartet."
: (root.actionKind === "primary" && root.health !== "ready"
? "Setup-Fenster wurde geöffnet. Omarchy Citizen übernimmt die technischen Schritte."
: "Aktion wurde gestartet.")
root.refreshStatus()
if (root.actionCloseOnSuccess)
closeAfterAction.restart()
} else {
var detail = String(actionErr.text || "").trim()
root.actionStatus = ""
root.actionError = detail !== ""
? detail
: "Die Aktion konnte nicht gestartet werden. Bitte erstelle ein Support-Paket."
}
}
}
Timer {
id: closeAfterAction
interval: 350
repeat: false
onTriggered: root.close()
}
Timer {
interval: 10000
repeat: true
running: root.opened
onTriggered: root.refreshStatus()
}
KeyboardPanel {
id: panel
anchorItem: root.anchorItem
owner: root.hostWidget || root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(450))
contentHeight: panel.fittedContentHeight(content.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
Column {
id: content
width: parent.width
spacing: Style.space(10)
Rectangle {
width: parent.width
radius: 18
color: root.surfaceRaised
border.color: root.healthColor()
border.width: 1
implicitHeight: heroContent.implicitHeight + Style.space(24)
Column {
id: heroContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.space(16)
spacing: Style.space(10)
Row {
width: parent.width
spacing: Style.space(10)
Rectangle {
width: Style.space(34)
height: Style.space(34)
radius: 10
color: root.accentSoft
border.color: root.healthColor()
border.width: 1
Text {
anchors.centerIn: parent
text: "✦"
color: root.healthColor()
font.family: root.uiFont
font.pixelSize: Style.font.title
font.bold: true
}
}
Column {
spacing: Style.space(2)
Text {
text: "CITIZEN LAUNCHER"
color: root.barForeground
font.family: root.uiFont
font.pixelSize: Style.font.title
font.bold: true
}
Text {
text: root.headline()
color: root.healthColor()
font.family: root.uiFont
font.pixelSize: Style.font.bodySmall
font.bold: true
}
}
}
Text {
width: parent.width
text: root.subline()
color: root.muted
font.family: root.uiFont
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
Button {
width: parent.width
text: actionProc.running
? "… WIRD GESTARTET"
: root.primaryText()
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
active: root.health === "ready" || actionProc.running
onClicked: root.runAction(
root.health === "hardware-blocked" ? "hardware-info" :
((root.health === "repair" || root.health === "launcher-repair") ? "repair" : "primary"),
root.health === "ready"
)
}
Text {
visible: root.actionStatus !== ""
width: parent.width
text: root.actionStatus
color: root.accent
font.family: root.uiFont
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
Rectangle {
visible: root.actionError !== ""
width: parent.width
radius: 10
color: Qt.rgba(root.danger.r, root.danger.g, root.danger.b, 0.10)
border.color: root.danger
border.width: 1
implicitHeight: actionErrorText.implicitHeight + Style.space(16)
Text {
id: actionErrorText
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.space(8)
text: root.actionError
color: root.danger
font.family: root.uiFont
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
}
}
Rectangle {
width: parent.width
radius: 16
color: root.surface
border.color: root.border
border.width: 1
implicitHeight: simpleActions.implicitHeight + Style.space(20)
Column {
id: simpleActions
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.space(14)
spacing: Style.space(9)
Text {
text: "HILFE OHNE LINUX-WISSEN"
color: root.accent
font.family: root.uiFont
font.pixelSize: Style.font.bodySmall
font.bold: true
}
Grid {
id: helpGrid
width: parent.width
columns: 2
columnSpacing: Style.space(7)
rowSpacing: Style.space(7)
readonly property real cellWidth: (width - columnSpacing) / 2
Button {
width: helpGrid.cellWidth
text: "↻ Problem beheben"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("repair")
}
Button {
width: helpGrid.cellWidth
text: "▣ Support-Paket erstellen"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("support")
}
}
Text {
width: parent.width
text: "Das Support-Paket sammelt Diagnoseinformationen automatisch und maskiert typische persönliche Daten vor dem Export."
color: root.muted
font.family: root.uiFont
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
}
Rectangle {
width: parent.width
radius: 16
color: root.surface
border.color: root.border
border.width: 1
implicitHeight: quickStatus.implicitHeight + Style.space(20)
Column {
id: quickStatus
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.space(14)
spacing: Style.space(8)
Row {
width: parent.width
spacing: Style.space(8)
StatusChip {
label: "LUG"
value: root.helperState === "ready" ? "bereit" : "fehlt"
chipColor: root.stateColor(root.helperState)
}
StatusChip {
label: "Launcher"
value: root.launcherState === "ready" ? "bereit" : "Setup"
chipColor: root.stateColor(root.launcherState)
}
StatusChip {
label: "Game"
value: root.gameState === "ready" ? "LIVE" : "Setup"
chipColor: root.stateColor(root.gameState)
}
}
Text {
width: parent.width
text: "Version " + root.pluginVersion +
" · Autopilot " + (root.autopilot === "true" ? "AKTIV" : "aus") +
" · LUG " + (root.managedLUGVersion !== "" ? root.managedLUGVersion : "—") +
" · Wine " + (root.managedWineVersion !== "" ? root.managedWineVersion : "—")
color: root.muted
font.family: root.uiFont
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
}
Button {
width: parent.width
text: root.advancedVisible ? "▲ Erweiterte Optionen ausblenden" : "▼ Erweiterte Optionen anzeigen"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.advancedVisible = !root.advancedVisible
}
Rectangle {
visible: root.advancedVisible
width: parent.width
radius: 16
color: root.surface
border.color: root.border
border.width: 1
implicitHeight: visible ? advancedContent.implicitHeight + Style.space(20) : 0
Column {
id: advancedContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.space(14)
spacing: Style.space(10)
Text {
text: "ERWEITERTE OPTIONEN"
color: root.accent
font.family: root.uiFont
font.pixelSize: Style.font.bodySmall
font.bold: true
}
StatusLine {
label: "GPU / Vulkan"
state: root.hardwareState === "blocked" ? "warning" : (root.hardwareState === "ready" ? "ready" : "unknown")
value: root.hardwareState === "blocked" ? "nicht spielbereit" : (root.hardwareGPU !== "" ? root.hardwareGPU : root.hardwareVulkan)
}
StatusLine {
label: "Abhängigkeiten"
state: root.depsState
value: root.depsState === "ready" ? "bereit" : root.depsMissing
}
StatusLine {
label: "Support-Helper"
state: root.helperState
value: root.helperState === "ready"
? ((root.helperVersion !== "" ? root.helperVersion : "gefunden") +
" · optional")
: "nicht installiert"
}
StatusLine {
label: "Wine-Prefix"
state: root.prefixState
value: root.prefixState === "ready"
? "initialisiert"
: (root.prefixState === "partial" ? "unvollständig · Reparatur möglich" : "nicht eingerichtet")
}
StatusLine {
label: "RSI Launcher"
state: root.launcherState
value: root.launcherState === "ready" ? "installiert" : "fehlt"
}
PanelSeparator { foreground: root.barForeground }
StatusLine {
label: "Autopilot"
state: root.autopilot === "true" ? "ready" : "warning"
value: root.autopilot === "true"
? "aktiv · Wartung alle 6 Stunden"
: "nicht aktiviert"
}
StatusLine {
label: "Managed Wine"
state: root.managedWineVersion !== "" ? "ready" : "unknown"
value: root.managedWineVersion !== "" ? root.managedWineVersion : "wird beim Setup geladen"
}
StatusLine {
label: "Managed DXVK"
state: root.managedDXVKVersion !== "" ? "ready" : "unknown"
value: root.managedDXVKVersion !== "" ? root.managedDXVKVersion : "nach Prefix-Setup"
}
Text {
text: "PLUGIN-UPDATES"
color: root.accent
font.family: root.uiFont
font.pixelSize: Style.font.bodySmall
font.bold: true
}
StatusLine {
label: "Update-Modus"
state: root.backendState === "ready" ? "ready" : "missing"
value: root.pluginManaged === "git"
? (root.pluginAuto === "true" ? "automatisch · alle ~6h" : "Git · manuell")
: "ZIP · Git-Installation für Auto-Updates nötig"
}
StatusLine {
label: "Plugin-Stand"
state: root.pluginUpdateState()
value: root.pluginUpdateText()
}
Grid {
id: pluginUpdateGrid
width: parent.width
columns: 2
columnSpacing: Style.space(7)
rowSpacing: Style.space(7)
readonly property real cellWidth: (width - columnSpacing) / 2
Button {
width: pluginUpdateGrid.cellWidth
text: "Plugin-Update prüfen"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("plugin-update-check")
}
Button {
width: pluginUpdateGrid.cellWidth
text: "Plugin jetzt aktualisieren"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
active: root.pluginUpdate === "available"
onClicked: root.runAction("plugin-update-now")
}
Button {
width: pluginUpdateGrid.cellWidth
text: root.pluginAuto === "true" ? "Auto-Updates ausschalten" : "Auto-Updates einschalten"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction(root.pluginAuto === "true" ? "plugin-auto-disable" : "plugin-auto-enable")
}
Button {
width: pluginUpdateGrid.cellWidth
text: "Updater-Log öffnen"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("updater-log")
}
}
Text {
width: parent.width
text: root.pluginManaged === "git"
? "Auto-Updates verwenden Omarchys eigenen Plugin-Updater mit Validierung und Rollback. Das beim Aktivieren vorhandene Git-Remote wird als vertrauenswürdige Quelle gespeichert."
: "Die aktuelle ZIP-Installation bleibt bewusst ohne unbekannte Update-Quelle. Sobald Omarchy Citizen aus einem Git-Repository installiert wird, erkennt das Backend dessen origin automatisch."
color: root.muted
font.family: root.uiFont
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
PanelSeparator { foreground: root.barForeground }
Grid {
id: setupGrid
width: parent.width
columns: 2
columnSpacing: Style.space(7)
rowSpacing: Style.space(7)
readonly property real cellWidth: (width - columnSpacing) / 2
Button {
width: setupGrid.cellWidth
text: "Autopilot synchronisieren"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("autopilot-maintain")
}
Button {
width: setupGrid.cellWidth
text: "Gaming-Stack aktualisieren"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("autopilot-maintain")
}
Button {
width: setupGrid.cellWidth
text: "Omarchy aktualisieren"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("system-update")
}
Button {
width: setupGrid.cellWidth
text: "Support-Helper öffnen"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("helper")
}
Button {
width: setupGrid.cellWidth
text: "Wine-Selbsttest"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("wine-doctor")
}
Button {
width: setupGrid.cellWidth
text: "DXVK automatisch prüfen"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("autopilot-maintain")
}
Button {
width: setupGrid.cellWidth
text: "Gaming-Stack reparieren"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("repair")
}
Button {
width: setupGrid.cellWidth
text: "Launcher-Dateien reparieren"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("repair")
}
Button {
width: setupGrid.cellWidth
text: "Start-Log öffnen"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("log")
}
Button {
width: setupGrid.cellWidth
text: "Plugin-Debuglog öffnen"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("debug-log")
}
}
PanelSeparator { foreground: root.barForeground }
Text {
text: "NGL · EXPERIMENTELL"
color: root.warning
font.family: root.uiFont
font.pixelSize: Style.font.bodySmall
font.bold: true
}
Grid {
id: nglGrid
width: parent.width
columns: 2
columnSpacing: Style.space(7)
rowSpacing: Style.space(7)
readonly property real cellWidth: (width - columnSpacing) / 2
Button {
width: nglGrid.cellWidth
text: "NGL Download"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("ngl-page")
}
Button {
width: nglGrid.cellWidth
text: "NGL installieren"
foreground: root.barForeground
fontFamily: root.uiFont
bordered: true
onClicked: root.runAction("ngl-install")
}
}
Text {
width: parent.width
text: "NGL bleibt bewusst optional. Der normale RSI/LUG-Weg funktioniert unabhängig davon."
color: root.warning
opacity: 0.82
font.family: root.uiFont
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
}
Text {
width: parent.width
text: "Ziel: spielen statt Linux administrieren. Für Support genügt normalerweise „Support-Paket erstellen“."
color: root.muted
font.family: root.uiFont
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
}
}
component StatusChip: Rectangle {
id: chip
property string label: ""
property string value: ""
property color chipColor: root.accent
radius: 999
color: Qt.rgba(chip.chipColor.r, chip.chipColor.g, chip.chipColor.b, 0.12)
border.color: chip.chipColor
border.width: 1
implicitWidth: chipRow.implicitWidth + Style.space(16)
implicitHeight: chipRow.implicitHeight + Style.space(9)
Row {
id: chipRow
anchors.centerIn: parent
spacing: Style.space(5)
Text {
text: chip.label
color: root.muted
font.family: root.uiFont
font.pixelSize: Style.font.caption
}
Text {
text: chip.value
color: chip.chipColor
font.family: root.uiFont
font.pixelSize: Style.font.caption
font.bold: true
}
}
}
component StatusLine: Rectangle {
id: statusLine
property string label: ""
property string value: ""
property string state: "unknown"
width: parent.width
radius: 11
color: root.surfaceRaised
border.color: root.border
border.width: 1
implicitHeight: lineRow.implicitHeight + Style.space(12)
Row {
id: lineRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Style.space(10)
spacing: Style.space(8)
Rectangle {
width: 9
height: 9
radius: 5
color: root.stateColor(statusLine.state)
}
Text {
width: Style.space(118)
text: statusLine.label
color: root.muted
font.family: root.uiFont
font.pixelSize: Style.font.bodySmall
}
Text {
width: Math.max(0, lineRow.width - Style.space(158))
text: root.stateIcon(statusLine.state) + " " + statusLine.value
color: root.barForeground
font.family: root.uiFont
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
}
}
}
+519
View File
@@ -0,0 +1,519 @@
#!/usr/bin/env bash
set -u
PLUGIN_ID="local.omarchy-citizen"
PLUGIN_VERSION="0.9.0"
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}"
XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
PLUGIN_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SELF="$(readlink -f "${BASH_SOURCE[0]}")"
STATE_DIR="$XDG_STATE_HOME/citizen-launcher"
CACHE_DIR="$XDG_CACHE_HOME/citizen-launcher"
DATA_DIR="$XDG_DATA_HOME/citizen-launcher"
LOG_FILE="$STATE_DIR/omarchy-citizen.log"
UPDATER_LOG="$STATE_DIR/updater.log"
RSI_LOG="$STATE_DIR/rsi-launcher.log"
SANITIZER="$PLUGIN_DIR/support-sanitize.py"
BACKEND_EXTERNAL="$HOME/.local/lib/citizen-launcher/citizen-launcher"
BACKEND_BUNDLED="$PLUGIN_DIR/backend/bin/citizen-launcher"
MANAGED_LUG="$DATA_DIR/vendor/lug-helper/lug-helper.appimage"
MANAGED_WINE="$DATA_DIR/vendor/wine/current"
mkdir -p "$STATE_DIR" "$CACHE_DIR"
ACTION="${1:-status}"
rotate_log() {
local max=$((4 * 1024 * 1024)) keep=$((2 * 1024 * 1024)) size=0
if [[ -f "$LOG_FILE" ]]; then
size="$(stat -c '%s' "$LOG_FILE" 2>/dev/null || echo 0)"
if (( size > max )); then
tail -c "$keep" "$LOG_FILE" > "$LOG_FILE.tmp" 2>/dev/null || true
mv -f "$LOG_FILE.tmp" "$LOG_FILE" 2>/dev/null || true
fi
fi
}
log_line() {
printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$$" "$*" >>"$LOG_FILE" 2>/dev/null || true
}
rotate_log
log_line "action=$ACTION version=$PLUGIN_VERSION"
notify() {
local title="$1" body="$2"
if command -v omarchy-notification-send >/dev/null 2>&1; then
omarchy-notification-send "$title" "$body" >/dev/null 2>&1 || true
elif command -v notify-send >/dev/null 2>&1; then
notify-send "$title" "$body" >/dev/null 2>&1 || true
fi
}
notify_error() {
local body="$1"
log_line "error=$body"
if command -v zenity >/dev/null 2>&1; then
zenity --error --width=560 --title="Citizen Launcher" --text="$body" >/dev/null 2>&1 || true
else
notify "Citizen Launcher" "$body"
printf 'Citizen Launcher: %s\n' "$body" >&2
fi
}
find_backend() {
if [[ -x "$BACKEND_EXTERNAL" ]]; then
printf '%s\n' "$BACKEND_EXTERNAL"
elif [[ -x "$BACKEND_BUNDLED" ]]; then
printf '%s\n' "$BACKEND_BUNDLED"
else
return 1
fi
}
backend_exec() {
local backend
backend="$(find_backend)" || {
printf 'Go-Backend nicht gefunden. Plugin bitte neu installieren.\n' >&2
return 127
}
"$backend" "$@"
}
backend_game_status() { backend_exec game-status; }
backend_plugin_status() { backend_exec status; }
backend_game_install() { backend_exec game-install; }
backend_game_launch() { backend_exec game-launch; }
backend_game_repair() { backend_exec game-repair; }
autopilot_enable() { backend_exec autopilot enable; }
autopilot_maintain() { backend_exec maintain; }
wine_doctor() { backend_exec doctor; }
kv_get() {
local key="$1"
sed -n "s/^${key}=//p" | head -n1
}
status() {
local game="" plugin=""
local health="setup" hardware="unknown" hardware_reason="" gpu="" vulkan="unknown"
local prefix="missing" launcher="missing" game_state="missing" wine="" dxvk="" lug="" game_autopilot="false"
local backend_state="missing" backend_version="" managed="manual" auto_apply="false" auto_maintain="false"
local update="unavailable" local_commit="" remote_commit="" maintenance=""
if game="$(backend_game_status 2>/dev/null)"; then
health="$(printf '%s\n' "$game" | kv_get health)"
hardware="$(printf '%s\n' "$game" | kv_get hardware)"
hardware_reason="$(printf '%s\n' "$game" | kv_get hardware_reason)"
gpu="$(printf '%s\n' "$game" | kv_get gpu)"
vulkan="$(printf '%s\n' "$game" | kv_get vulkan)"
prefix="$(printf '%s\n' "$game" | kv_get prefix_state)"
launcher="$(printf '%s\n' "$game" | kv_get launcher_state)"
game_state="$(printf '%s\n' "$game" | kv_get game_state)"
wine="$(printf '%s\n' "$game" | kv_get wine_version)"
dxvk="$(printf '%s\n' "$game" | kv_get dxvk_version)"
lug="$(printf '%s\n' "$game" | kv_get lug_version)"
game_autopilot="$(printf '%s\n' "$game" | kv_get autopilot)"
fi
if plugin="$(backend_plugin_status 2>/dev/null)"; then
backend_state="ready"
backend_version="$(printf '%s\n' "$plugin" | kv_get backend_version)"
managed="$(printf '%s\n' "$plugin" | kv_get managed)"
auto_apply="$(printf '%s\n' "$plugin" | kv_get auto_apply)"
auto_maintain="$(printf '%s\n' "$plugin" | kv_get auto_maintain)"
update="$(printf '%s\n' "$plugin" | kv_get update)"
local_commit="$(printf '%s\n' "$plugin" | kv_get local_commit)"
remote_commit="$(printf '%s\n' "$plugin" | kv_get remote_commit)"
maintenance="$(printf '%s\n' "$plugin" | kv_get maintenance_result)"
fi
[[ -n "$health" ]] || health="setup"
[[ -n "$prefix" ]] || prefix="missing"
[[ -n "$launcher" ]] || launcher="missing"
[[ -n "$game_state" ]] || game_state="missing"
[[ -n "$hardware" ]] || hardware="unknown"
[[ -n "$vulkan" ]] || vulkan="unknown"
[[ -n "$update" ]] || update="unavailable"
printf 'plugin_version=%s\n' "$PLUGIN_VERSION"
printf 'health=%s\n' "$health"
printf 'deps=ready\n'
printf 'deps_missing=\n'
printf 'helper=%s\n' "$([[ -n "$lug" ]] && echo ready || echo missing)"
printf 'helper_managed=optional\n'
printf 'helper_version=%s\n' "$lug"
printf 'update=unknown\n'
printf 'update_version=\n'
printf 'prefix=%s\n' "$prefix"
printf 'prefix_detail=%s\n' "$([[ "$prefix" == partial ]] && echo unvollständig || true)"
printf 'launcher=%s\n' "$launcher"
printf 'game=%s\n' "$game_state"
printf 'backend=%s\n' "$backend_state"
printf 'backend_version=%s\n' "$backend_version"
printf 'plugin_managed=%s\n' "$managed"
printf 'plugin_auto=%s\n' "$auto_apply"
printf 'plugin_update=%s\n' "$update"
printf 'plugin_local_commit=%s\n' "$local_commit"
printf 'plugin_remote_commit=%s\n' "$remote_commit"
printf 'autopilot=%s\n' "$([[ "$auto_maintain" == true ]] && echo true || echo "$game_autopilot")"
printf 'managed_lug_version=%s\n' "$lug"
printf 'managed_wine_version=%s\n' "$wine"
printf 'managed_dxvk_version=%s\n' "$dxvk"
printf 'maintenance_result=%s\n' "$maintenance"
printf 'hardware=%s\n' "$hardware"
printf 'hardware_reason=%s\n' "$hardware_reason"
printf 'hardware_gpu=%s\n' "$gpu"
printf 'hardware_vulkan=%s\n' "$vulkan"
}
hardware_info() {
local game reason gpu vulkan
game="$(backend_game_status 2>/dev/null || true)"
reason="$(printf '%s\n' "$game" | kv_get hardware_reason)"
gpu="$(printf '%s\n' "$game" | kv_get gpu)"
vulkan="$(printf '%s\n' "$game" | kv_get vulkan)"
if command -v zenity >/dev/null 2>&1; then
zenity --warning --width=620 --title="Omarchy Citizen – Hardware" \
--text="<b>Star Citizen ist auf diesem System noch nicht spielbereit.</b>\n\nGPU:\n${gpu:-nicht erkannt}\n\nVulkan: ${vulkan:-unbekannt}\n\n${reason:-Hardware-Prüfung fehlgeschlagen.}" >/dev/null 2>&1 || true
else
printf 'GPU: %s\nVulkan: %s\n%s\n' "${gpu:-nicht erkannt}" "${vulkan:-unbekannt}" "${reason:-Hardware-Prüfung fehlgeschlagen.}"
fi
}
probe_detached_start() {
local label="$1"; shift
"$@" >>"$LOG_FILE" 2>&1 &
local pid=$! rc=0
sleep 0.35
if ! kill -0 "$pid" 2>/dev/null; then
wait "$pid" || rc=$?
if (( rc != 0 )); then
log_line "$label failed rc=$rc"
return "$rc"
fi
else
disown "$pid" 2>/dev/null || true
fi
log_line "$label accepted"
}
launch_terminal_action() {
local action="$1"
log_line "launch-terminal action=$action"
if command -v uwsm-app >/dev/null 2>&1 && command -v xdg-terminal-exec >/dev/null 2>&1; then
probe_detached_start "uwsm-xdg-terminal" setsid uwsm-app -- xdg-terminal-exec \
--app-id=org.omarchy.terminal --title="Citizen Launcher" -e bash "$SELF" "$action" && return 0
fi
if command -v xdg-terminal-exec >/dev/null 2>&1; then
probe_detached_start "xdg-terminal" xdg-terminal-exec --title="Citizen Launcher" -e bash "$SELF" "$action" && return 0
fi
if command -v omarchy-launch-floating-terminal-with-presentation >/dev/null 2>&1; then
probe_detached_start "omarchy-terminal" omarchy-launch-floating-terminal-with-presentation bash "$SELF" "$action" && return 0
fi
notify_error "Das Omarchy-Citizen-Terminal konnte nicht geöffnet werden. Bitte ein Support-Paket erstellen."
return 1
}
install_stack_terminal() {
set -Ee
exec > >(tee -a "$LOG_FILE") 2>&1
setup_failed() {
local rc=$?
trap - ERR
echo
echo "=================================================="
echo " SETUP KONNTE NICHT ABGESCHLOSSEN WERDEN"
echo "=================================================="
echo "Fehlercode: $rc"
echo "Alle Details wurden protokolliert. Im SC-Panel genügt 'Support-Paket erstellen'."
notify "Citizen Launcher" "Setup fehlgeschlagen. Bitte Support-Paket erstellen."
if [[ -t 0 ]]; then read -r -p "Drücke Enter zum Schließen … " _ || true; fi
exit "$rc"
}
trap setup_failed ERR
echo "=================================================="
echo " Omarchy Citizen – Spielbereit machen"
echo "=================================================="
echo
local game health reason
game="$(backend_game_status 2>/dev/null || true)"
health="$(printf '%s\n' "$game" | kv_get health)"
if [[ "$health" == "hardware-blocked" ]]; then
reason="$(printf '%s\n' "$game" | kv_get hardware_reason)"
echo "Hardware-Prüfung nicht bestanden:"
echo "${reason:-Keine Star-Citizen-taugliche Vulkan-GPU erkannt.}"
trap - ERR
return 2
fi
echo "1/4 Autopilot aktivieren und Gaming-Stack synchronisieren …"
autopilot_enable
echo
echo "2/4 Wine-Runner auf diesem Rechner prüfen …"
wine_doctor
echo
echo "3/4 Wine-Prefix, Komponenten und offiziellen RSI Launcher einrichten …"
backend_game_install
echo
echo "4/4 Wartungsstand abschließend synchronisieren …"
autopilot_maintain || true
trap - ERR
echo
echo "=================================================="
echo " SOFTWARE-STACK BEREIT"
echo "=================================================="
echo "Der offizielle RSI Launcher ist eingerichtet. Anmeldung und Spieldownload erfolgen dort."
notify "Citizen Launcher" "Software-Stack ist bereit. RSI Launcher kann gestartet werden."
}
repair_stack_terminal() {
set -Ee
exec > >(tee -a "$LOG_FILE") 2>&1
repair_failed() {
local rc=$?
trap - ERR
echo
echo "REPARATUR FEHLGESCHLAGEN (Code $rc)"
echo "Bitte im SC-Panel ein Support-Paket erstellen."
notify "Citizen Launcher" "Reparatur fehlgeschlagen. Bitte Support-Paket erstellen."
if [[ -t 0 ]]; then read -r -p "Drücke Enter zum Schließen … " _ || true; fi
exit "$rc"
}
trap repair_failed ERR
echo "Omarchy Citizen repariert den verwalteten Gaming-Stack …"
autopilot_maintain || true
backend_game_repair
wine_doctor
trap - ERR
echo "Reparatur abgeschlossen."
notify "Citizen Launcher" "Gaming-Stack wurde repariert."
}
update_stack_terminal() {
set -Ee
exec > >(tee -a "$LOG_FILE") 2>&1
echo "Synchronisiere Wine, DXVK, RSI Launcher und optionale Support-Werkzeuge …"
autopilot_maintain
echo "Gaming-Stack ist aktuell."
}
system_update_terminal() {
set -Ee
exec > >(tee -a "$LOG_FILE") 2>&1
if ! command -v omarchy >/dev/null 2>&1; then
echo "Omarchy-Befehl nicht gefunden." >&2
return 1
fi
echo "Starte den offiziellen Omarchy-Systemupdate-Pfad …"
omarchy update -y
}
primary_action() {
local game health
game="$(backend_game_status 2>/dev/null || true)"
health="$(printf '%s\n' "$game" | kv_get health)"
case "$health" in
hardware-blocked) hardware_info ;;
ready|install-game) backend_game_launch ;;
repair|launcher-repair) launch_terminal_action repair-stack-terminal ;;
*) launch_terminal_action install-stack-terminal ;;
esac
}
repair_assistant() {
launch_terminal_action repair-stack-terminal
}
plugin_update_check() { backend_exec check; }
plugin_update_now() { backend_exec update; }
plugin_auto_enable() { backend_exec auto enable; }
plugin_auto_disable() { backend_exec auto disable; }
open_file() {
local path="$1"
[[ -f "$path" ]] || { notify_error "Datei noch nicht vorhanden: $path"; return 1; }
xdg-open "$path" >/dev/null 2>&1 &
}
open_debug_log() { open_file "$LOG_FILE"; }
open_updater_log() { open_file "$UPDATER_LOG"; }
open_log() { open_file "$RSI_LOG"; }
open_wiki() { xdg-open "https://wiki.starcitizen-lug.org" >/dev/null 2>&1 & }
open_ngl_page() { xdg-open "https://www.nglproject.com/de#downloads" >/dev/null 2>&1 & }
open_helper() {
if [[ -x "$MANAGED_LUG" ]]; then
nohup env APPIMAGE_EXTRACT_AND_RUN=1 "$MANAGED_LUG" >>"$LOG_FILE" 2>&1 &
else
open_wiki
fi
}
sanitize_to() {
local source="$1" target="$2"
[[ -f "$source" ]] || return 0
if command -v python3 >/dev/null 2>&1 && [[ -f "$SANITIZER" ]]; then
python3 "$SANITIZER" <"$source" >"$target"
else
sed -e "s#${HOME//\#/\\#}#~#g" "$source" >"$target"
fi
}
capture_sanitized() {
local target="$1"; shift
local tmp="$CACHE_DIR/capture-$$-$RANDOM.txt"
"$@" >"$tmp" 2>&1 || true
sanitize_to "$tmp" "$target"
rm -f "$tmp"
}
support_build() {
local stamp support_id work downloads archive game_prefix=""
stamp="$(date '+%Y%m%d-%H%M%S')"
support_id="OC-${stamp}-$(printf '%s-%s-%s' "$stamp" "$$" "$RANDOM" | sha256sum | cut -c1-6)"
downloads="$(command -v xdg-user-dir >/dev/null 2>&1 && xdg-user-dir DOWNLOAD 2>/dev/null || true)"
[[ -n "$downloads" && -d "$downloads" ]] || downloads="$HOME/Downloads"
mkdir -p "$downloads"
work="$CACHE_DIR/support-$support_id"
rm -rf "$work" && mkdir -p "$work"
cat >"$work/README_SUPPORT.txt" <<SUPPORT
Omarchy Citizen Support-Paket
Support-ID: $support_id
Plugin-Version: $PLUGIN_VERSION
Erstellt: $(date '+%Y-%m-%d %H:%M:%S %z')
Das Paket enthält technische Diagnoseinformationen. Home-Pfad, Benutzername,
Hostname, E-Mail/IP/MAC und typische Token-/Sessionwerte werden beim Export
best-effort maskiert. Keine Browserdaten, SSH-Schlüssel oder Passwortdateien.
SUPPORT
status >"$work/citizen-status.txt" 2>&1 || true
capture_sanitized "$work/backend-game-status.txt" backend_exec game-status
capture_sanitized "$work/backend-status.txt" backend_exec status
capture_sanitized "$work/wine-doctor.txt" backend_exec doctor
if command -v omarchy >/dev/null 2>&1; then
capture_sanitized "$work/plugin-validation.txt" omarchy plugin validate "$PLUGIN_DIR"
if omarchy debug --no-sudo --print >"$CACHE_DIR/omarchy-debug-$$.txt" 2>&1; then
sanitize_to "$CACHE_DIR/omarchy-debug-$$.txt" "$work/omarchy-debug.txt"
else
{ omarchy version 2>/dev/null || true; omarchy commands --all 2>/dev/null || true; } >"$CACHE_DIR/omarchy-debug-$$.txt"
sanitize_to "$CACHE_DIR/omarchy-debug-$$.txt" "$work/omarchy-debug.txt"
fi
rm -f "$CACHE_DIR/omarchy-debug-$$.txt"
fi
{
echo "=== kernel ==="; uname -rmo 2>/dev/null || true
echo "=== memory ==="; free -h 2>/dev/null || true
echo "=== filesystems ==="; df -hT / "$HOME" 2>/dev/null || true
echo "=== gpu ==="; lspci -nnk 2>/dev/null | grep -A5 -Ei 'VGA|3D|Display' || true
echo "=== vulkan ==="; command -v vulkaninfo >/dev/null 2>&1 && vulkaninfo --summary 2>&1 || true
echo "=== limits ==="; sysctl vm.max_map_count 2>/dev/null || true; ulimit -Hn 2>/dev/null || true
} >"$CACHE_DIR/system-$$.txt"
sanitize_to "$CACHE_DIR/system-$$.txt" "$work/system.txt"
rm -f "$CACHE_DIR/system-$$.txt"
for pair in "$LOG_FILE:omarchy-citizen.log" "$UPDATER_LOG:updater.log" "$RSI_LOG:rsi-launcher.log"; do
local src="${pair%%:*}" dst="${pair#*:}"
if [[ -f "$src" ]]; then
tail -n 1800 "$src" >"$CACHE_DIR/log-$$.txt"
sanitize_to "$CACHE_DIR/log-$$.txt" "$work/$dst"
rm -f "$CACHE_DIR/log-$$.txt"
fi
done
game_prefix="$(backend_game_status 2>/dev/null | kv_get prefix || true)"
if [[ -n "$game_prefix" ]]; then
printf 'prefix=%s\n' "$game_prefix" >"$CACHE_DIR/prefix-$$.txt"
sanitize_to "$CACHE_DIR/prefix-$$.txt" "$work/prefix.txt"
rm -f "$CACHE_DIR/prefix-$$.txt"
fi
archive="$downloads/Omarchy-Citizen-Support-$support_id.tar.gz"
tar -C "$work" -czf "$archive" .
rm -rf "$work"
notify "Citizen Launcher" "Support-Paket erstellt: $(basename "$archive")"
command -v xdg-open >/dev/null 2>&1 && xdg-open "$downloads" >/dev/null 2>&1 &
printf '%s\n' "$archive"
}
ngl_info() {
local msg="NGL ist ein optionaler Community-Mod für den RSI Launcher. Omarchy Citizen installiert ihn nur nach deiner ausdrücklichen Dateiauswahl in den verwalteten Wine-Prefix."
if command -v zenity >/dev/null 2>&1; then
zenity --info --width=540 --title="Omarchy Citizen – NGL" --text="$msg" >/dev/null 2>&1 || true
else
printf '%s\n' "$msg"
fi
}
ngl_install() {
local game prefix selected exe workdir wine
command -v zenity >/dev/null 2>&1 || { notify_error "Für die NGL-Dateiauswahl wird zenity benötigt."; return 1; }
game="$(backend_game_status)"
prefix="$(printf '%s\n' "$game" | kv_get prefix)"
[[ -n "$prefix" && -d "$prefix/drive_c" ]] || { notify_error "Star Citizen muss zuerst eingerichtet werden."; return 1; }
wine="$(readlink -f "$MANAGED_WINE")/bin/wine"
[[ -x "$wine" ]] || { notify_error "Der verwaltete Wine-Runner fehlt."; return 1; }
selected="$(zenity --file-selection --title="NGL Installer auswählen" --filename="$HOME/Downloads/" \
--file-filter="NGL Installer | *.zip *.ZIP *.exe *.EXE" --file-filter="Alle Dateien | *" 2>/dev/null)" || return 0
[[ -f "$selected" ]] || return 1
exe="$selected"
if [[ "${selected,,}" == *.zip ]]; then
workdir="$CACHE_DIR/ngl-installer"
rm -rf "$workdir" && mkdir -p "$workdir"
unzip -q "$selected" -d "$workdir" || { notify_error "NGL-ZIP konnte nicht entpackt werden."; return 1; }
exe="$(find "$workdir" -type f -iname '*.exe' -print -quit)"
fi
[[ -n "$exe" && -f "$exe" ]] || { notify_error "Kein Windows-Installer gefunden."; return 1; }
zenity --question --width=560 --title="NGL experimentell installieren?" \
--ok-label="Installer starten" --cancel-label="Abbrechen" \
--text="NGL ist Community-Code und läuft im selben Wine-Prefix wie der RSI Launcher. Bitte nur Downloads von nglproject.com verwenden." 2>/dev/null || return 0
nohup env WINEPREFIX="$prefix" WINEDEBUG=-all "$wine" "$exe" >>"$LOG_FILE" 2>&1 &
}
case "$ACTION" in
status) status ;;
hardware-info) hardware_info ;;
primary) primary_action ;;
setup|install-stack) launch_terminal_action install-stack-terminal ;;
install-stack-terminal) install_stack_terminal ;;
repair|repair-interactive|recover-prefix) repair_assistant ;;
repair-stack-terminal|recover-prefix-terminal) repair_stack_terminal ;;
support|support-build) support_build ;;
debug-log) open_debug_log ;;
log) open_log ;;
updater-log) open_updater_log ;;
plugin-update-check|check-update) plugin_update_check ;;
plugin-update-now) plugin_update_now ;;
plugin-auto-enable) plugin_auto_enable ;;
plugin-auto-disable) plugin_auto_disable ;;
autopilot-enable) autopilot_enable ;;
autopilot-maintain|update-helper) launch_terminal_action update-stack-terminal ;;
update-stack-terminal|update-helper-terminal) update_stack_terminal ;;
wine-doctor) launch_terminal_action wine-doctor-terminal ;;
wine-doctor-terminal) set -e; wine_doctor ;;
system-update) launch_terminal_action system-update-terminal ;;
system-update-terminal) system_update_terminal ;;
launch) backend_game_launch ;;
helper) open_helper ;;
preflight) open_helper ;;
runners|dxvk) launch_terminal_action update-stack-terminal ;;
repair-launch|repair-rsi) launch_terminal_action repair-stack-terminal ;;
dirs) ( xdg-open "$DATA_DIR" >/dev/null 2>&1 & ) ;;
wiki) open_wiki ;;
ngl-page) open_ngl_page ;;
ngl-info) ngl_info ;;
ngl-install|ngl-install-interactive) ngl_install ;;
*) notify_error "Unbekannte Omarchy-Citizen-Aktion: $ACTION"; exit 2 ;;
esac
+21
View File
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"id": "local.omarchy-citizen",
"name": "Citizen Launcher · Omarchy",
"version": "0.9.0",
"author": "Community prototype",
"license": "MIT",
"description": "Optional Omarchy bar integration for the distro-neutral Citizen Launcher.",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "BarWidget.qml"
},
"barWidget": {
"displayName": "Omarchy Citizen",
"category": "Gaming",
"allowMultiple": false,
"defaultSection": "right"
}
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
import os
import re
import socket
import sys
HOME = os.path.expanduser("~")
USER = os.environ.get("USER", "")
HOST = socket.gethostname()
patterns = [
(re.compile(r"(?i)(https?://)[^/@\s]+@"), r"\1<credentials>@"),
(re.compile(re.escape(HOME)), "~"),
(re.compile(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b"), "<email>"),
(re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "<ip>"),
(re.compile(r"(?i)\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b"), "<mac>"),
(re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+"), "Bearer <redacted>"),
(re.compile(r"(?i)\b(authorization|password|passwd|secret|token|access_token|refresh_token|session|cookie)\s*[:=]\s*[^\s,;]+"),
r"\1=<redacted>"),
(re.compile(r"(?i)([?&](?:token|access_token|refresh_token|session|auth|key|secret)=)[^&#\s]+"),
r"\1<redacted>"),
(re.compile(r"\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"), "<jwt>"),
]
if USER:
patterns.append((re.compile(rf"(?<![A-Za-z0-9_.-]){re.escape(USER)}(?![A-Za-z0-9_.-])"), "<user>"))
if HOST:
patterns.append((re.compile(rf"(?<![A-Za-z0-9_.-]){re.escape(HOST)}(?![A-Za-z0-9_.-])"), "<host>"))
for line in sys.stdin:
for pattern, repl in patterns:
line = pattern.sub(repl, line)
sys.stdout.write(line)
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="0.9.0"; ARCH="amd64"; WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
PKG="$WORK/citizen-launcher_${VERSION}_${ARCH}"
mkdir -p "$PKG/DEBIAN" "$PKG/usr/bin" "$PKG/usr/share/applications"
install -m755 "$ROOT/backend/bin/citizen-launcher" "$PKG/usr/bin/citizen-launcher"
cat > "$PKG/DEBIAN/control" <<EOF
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
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
[Desktop Entry]
Name=Citizen Launcher
Comment=Star Citizen for Linux
Exec=citizen-launcher gui
Terminal=false
Type=Application
Categories=Game;
StartupNotify=true
EOF
mkdir -p "$ROOT/dist"
dpkg-deb --build --root-owner-group "$PKG" "$ROOT/dist/citizen-launcher_${VERSION}_${ARCH}.deb"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
mkdir -p "$ROOT/dist"
tar -C "$ROOT/backend/bin" -czf "$ROOT/dist/citizen-launcher-0.9.0-linux-amd64.tar.gz" citizen-launcher
+17
View File
@@ -0,0 +1,17 @@
Name: citizen-launcher
Version: 0.9.0
Release: 1%{?dist}
Summary: Star Citizen launcher and self-maintaining Wine stack for Linux
License: MIT
BuildArch: x86_64
Requires: ca-certificates, tar, xdg-utils
%description
Distro-neutral Citizen Launcher with Wine, DXVK, RSI Launcher management and local GUI.
%install
mkdir -p %{buildroot}%{_bindir}
install -m755 citizen-launcher %{buildroot}%{_bindir}/citizen-launcher
%files
%{_bindir}/citizen-launcher
+7 -18
View File
@@ -1,21 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
for f in citizenctl install.sh INSTALLIEREN.sh uninstall.sh; do
bash -n "$f"
done
python3 -m py_compile support-sanitize.py
(
cd backend
gofmt -w cmd/omarchy-citizen-backend/*.go
go test ./...
go vet ./...
bash build.sh
)
bash tests/smoke-citizenctl.sh
echo "Omarchy Citizen verification: OK"
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)
"$ROOT/backend/bin/citizen-launcher" --version | grep -qx '0.9.0'
"$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'
+6 -23
View File
@@ -1,25 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
PLUGIN_ID="local.omarchy-citizen"
DEST="$HOME/.config/omarchy/plugins/$PLUGIN_ID"
BACKEND="$HOME/.local/lib/omarchy-citizen/omarchy-citizen-backend"
[[ -x "$BACKEND" ]] && "$BACKEND" uninstall-service >/dev/null 2>&1 || true
omarchy plugin disable "$PLUGIN_ID" 2>/dev/null || true
BACKEND="$HOME/.local/lib/omarchy-citizen/omarchy-citizen-backend"
if [[ -x "$BACKEND" ]]; then
"$BACKEND" auto disable >/dev/null 2>&1 || true
"$BACKEND" uninstall-service >/dev/null 2>&1 || true
fi
rm -f -- "$BACKEND"
rmdir "$HOME/.local/lib/omarchy-citizen" 2>/dev/null || true
if [[ -d "$DEST" ]]; then
rm -rf -- "$DEST"
fi
omarchy-shell shell rescanPlugins 2>/dev/null || true
echo "Omarchy Citizen wurde entfernt."
echo "Star Citizen und der LUG Helper wurden NICHT entfernt."
BIN="$HOME/.local/bin/citizen-launcher"
[[ -x "$BIN" ]] && "$BIN" uninstall-service >/dev/null 2>&1 || true
rm -f "$HOME/.local/bin/citizen-launcher" "$HOME/.local/lib/citizen-launcher/citizen-launcher"
rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/applications/io.github.citizenlauncher.CitizenLauncher.desktop"
if command -v omarchy >/dev/null 2>&1; then omarchy plugin disable local.omarchy-citizen >/dev/null 2>&1 || true; fi
echo "Citizen Launcher entfernt. Spiele/PREFIX/Downloads bleiben erhalten."