v0.9.0
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -1,3 +1,3 @@
|
||||
module omarchy-citizen/backend
|
||||
module citizen-launcher/backend
|
||||
|
||||
go 1.23
|
||||
|
||||
@@ -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 ]]
|
||||
|
||||
@@ -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"
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user