v1.1.1
Citizen Launcher CI / verify (push) Failing after 1m18s
Citizen Launcher CI / arch-package (push) Failing after 1m19s
Citizen Launcher CI / rpm-package (push) Failing after 1m21s

This commit is contained in:
2026-09-01 19:50:44 +02:00
parent 9bcd84587e
commit 4a48f9b8c5
46 changed files with 2023 additions and 389 deletions
+64
View File
@@ -0,0 +1,64 @@
name: Citizen Launcher CI
on:
push:
pull_request:
permissions:
code: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23.x'
cache: false
- name: Install verification dependencies
shell: bash
run: |
set -euo pipefail
SUDO=""
command -v sudo >/dev/null 2>&1 && SUDO=sudo
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends python3 python3-yaml dpkg-dev zstd
- name: Full regression suite
run: ./tests/full-verify.sh
rpm-package:
runs-on: ubuntu-latest
container: fedora:44
steps:
- name: Build dependencies
run: dnf -y install git golang rpm-build systemd-rpm-macros curl python3
- uses: actions/checkout@v4
- name: Build backend and RPM
run: |
set -euo pipefail
./build.sh
./packaging/build-rpm.sh
VERSION="$(tr -d '[:space:]' < VERSION)"
rpm -qpi "dist/citizen-launcher-${VERSION}-1.linux.x86_64.rpm"
arch-package:
runs-on: ubuntu-latest
container: archlinux:base-devel
env:
WORKSPACE: ${{ gitea.workspace }}
steps:
- name: Build dependencies
run: pacman -Syu --noconfirm --needed git go zstd curl python
- uses: actions/checkout@v4
- name: Build backend
run: ./build.sh
- name: Build pacman package as unprivileged user
shell: bash
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < VERSION)"
useradd -m builder
chown -R builder:builder "$WORKSPACE"
su builder -s /bin/bash -c "cd '$WORKSPACE' && ./packaging/build-arch.sh"
test -s "dist/citizen-launcher-${VERSION}-1-x86_64.pkg.tar.zst"
+143
View File
@@ -0,0 +1,143 @@
name: Release Citizen Launcher
on:
push:
tags:
- 'v*'
permissions:
code: read
releases: write
env:
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ gitea.token }}
RELEASE_SOURCE: gitea:${{ gitea.api_url }}/repos/${{ gitea.repository }}
TAG_NAME: ${{ gitea.ref_name }}
jobs:
prepare-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify tag and VERSION
shell: bash
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < VERSION)"
test "$TAG_NAME" = "v${VERSION}"
- name: Create Gitea release
shell: bash
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < VERSION)"
./scripts/gitea-release.sh ensure \
"$TAG_NAME" \
"Citizen Launcher ${VERSION}" \
RELEASE_NOTES.md \
"${{ gitea.sha }}"
deb-and-generic:
needs: prepare-release
runs-on: ubuntu-latest
env:
CITIZEN_LAUNCHER_RELEASE_REPO: gitea:${{ gitea.api_url }}/repos/${{ gitea.repository }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23.x'
cache: false
- name: Install build dependencies
shell: bash
run: |
set -euo pipefail
SUDO=""
command -v sudo >/dev/null 2>&1 && SUDO=sudo
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends python3 python3-yaml dpkg-dev zstd curl
- name: Full regression and package suite
run: ./tests/full-verify.sh
- name: Upload Debian and generic assets to Gitea
shell: bash
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < VERSION)"
./scripts/gitea-release.sh upload "$TAG_NAME" "dist/citizen-launcher_${VERSION}_amd64.deb"
./scripts/gitea-release.sh upload "$TAG_NAME" "dist/citizen-launcher-${VERSION}-linux-amd64.tar.gz"
rpm:
needs: prepare-release
runs-on: ubuntu-latest
container: fedora:44
env:
CITIZEN_LAUNCHER_RELEASE_REPO: gitea:${{ gitea.api_url }}/repos/${{ gitea.repository }}
steps:
- name: Build dependencies
run: dnf -y install git golang rpm-build systemd-rpm-macros curl python3
- uses: actions/checkout@v4
- name: Verify tag and build RPM
shell: bash
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < VERSION)"
test "$TAG_NAME" = "v${VERSION}"
./build.sh
./packaging/build-rpm.sh
rpm -qpi "dist/citizen-launcher-${VERSION}-1.linux.x86_64.rpm"
- name: Upload RPM to Gitea
shell: bash
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < VERSION)"
./scripts/gitea-release.sh upload "$TAG_NAME" "dist/citizen-launcher-${VERSION}-1.linux.x86_64.rpm"
arch:
needs: prepare-release
runs-on: ubuntu-latest
container: archlinux:base-devel
env:
CITIZEN_LAUNCHER_RELEASE_REPO: gitea:${{ gitea.api_url }}/repos/${{ gitea.repository }}
WORKSPACE: ${{ gitea.workspace }}
steps:
- name: Build dependencies
run: pacman -Syu --noconfirm --needed git go zstd curl python
- uses: actions/checkout@v4
- name: Verify tag and build Arch package
shell: bash
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < VERSION)"
test "$TAG_NAME" = "v${VERSION}"
./build.sh
useradd -m builder
chown -R builder:builder "$WORKSPACE"
su builder -s /bin/bash -c "cd '$WORKSPACE' && CITIZEN_LAUNCHER_RELEASE_REPO='$CITIZEN_LAUNCHER_RELEASE_REPO' ./packaging/build-arch.sh"
test -s "dist/citizen-launcher-${VERSION}-1-x86_64.pkg.tar.zst"
- name: Upload Arch package to Gitea
shell: bash
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < VERSION)"
./scripts/gitea-release.sh upload "$TAG_NAME" "dist/citizen-launcher-${VERSION}-1-x86_64.pkg.tar.zst"
finalize-release:
needs: [deb-and-generic, rpm, arch]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download release packages and create checksums
shell: bash
run: |
set -euo pipefail
rm -rf release-assets
./scripts/gitea-release.sh download-assets "$TAG_NAME" release-assets
VERSION="$(tr -d '[:space:]' < VERSION)"
test -s "release-assets/citizen-launcher_${VERSION}_amd64.deb"
test -s "release-assets/citizen-launcher-${VERSION}-1.linux.x86_64.rpm"
test -s "release-assets/citizen-launcher-${VERSION}-1-x86_64.pkg.tar.zst"
test -s "release-assets/citizen-launcher-${VERSION}-linux-amd64.tar.gz"
(cd release-assets && sha256sum * | LC_ALL=C sort -k2 > SHA256SUMS.txt)
- name: Publish SHA256SUMS on Gitea release
run: ./scripts/gitea-release.sh upload "$TAG_NAME" release-assets/SHA256SUMS.txt
+45 -33
View File
@@ -1,35 +1,51 @@
# Citizen Launcher architecture
## One source of truth
## One gaming core, multiple Linux integrations
The Go core under `backend/cmd/citizen-launcher` owns detection, setup, repair, launch, maintenance, support and self-update. Shell/QML integrations are adapters only; they must not duplicate the gaming-stack implementation.
The Go core under `backend/cmd/citizen-launcher` is the only implementation of detection, setup, repair, launch, maintenance, support and self-update. Debian/RPM/Arch packages and the optional Omarchy widget are adapters around that core.
```text
Standalone GUI / Omarchy adapter
│
▼
Citizen Launcher Go core
│
┌──────────┼───────────┐
▼ ▼ ▼
Preflight Wine RSI metadata
selector │
+ test ▼
│ │ verified installer
└────┬─────┘ │
▼ ▼
managed Wine prefix
│ │ │
▼ ▼ ▼
Winetricks DXVK PowerShell wrapper
└───────┬────────┘
▼
RSI Launcher / Game
Standalone GUI / Omarchy adapter
│
▼
Citizen Launcher Go core
│
┌─────────────┼──────────────┐
▼ ▼ ▼
Distro/preflight Wine RSI metadata
+ package mode selector + verifier
│ + test │
└──────────┬──┴───────┬───────┘
▼ ▼
managed Wine prefix
Winetricks / DXVK /
PowerShell wrapper
│
▼
RSI Launcher / Game
```
## Activation model
## Distribution abstraction
Mutable downloads are staged first. Executable GitHub release assets require a SHA-256 digest. A Wine candidate is extracted to a staging directory, tested against a throw-away prefix, and only then becomes `vendor/wine/current`. The previous validated runner is retained.
`platform.go` normalizes `/etc/os-release` into Debian, Fedora/RHEL, Arch, SUSE or generic families. Package-manager discovery is family-aware so a foreign tool in `$PATH` cannot accidentally select the wrong update strategy.
Native package formats share the same payload:
- `/usr/bin/citizen-launcher`
- freedesktop desktop entry, icon and AppStream metadata
- `vm.max_map_count` and file-limit policy
- systemd package self-update timer
- release-repository trust configuration
Only packaging metadata and the native package database differ.
## Mutable vs immutable systems
Mutable package installs may update Citizen Launcher through APT, RPM or pacman after release-digest and package-metadata verification. OSTree/transactional/SteamOS-style systems are detected and their base image is left untouched. A user-mode install uses the verified generic tarball instead.
## Activation and rollback model
Mutable gaming-stack downloads are staged first. Executable release assets require an expected digest. A Wine candidate is extracted to a staging directory, tested against a throw-away prefix, and only then becomes `vendor/wine/current`; the previous validated runner is retained.
The real game prefix is never used as the Wine compatibility test target.
@@ -37,14 +53,10 @@ The real game prefix is never used as the Wine compatibility test target.
- `gui.lock`: one GUI backend process per user.
- in-memory GUI job gate: one long-running GUI action at a time.
- `maintenance.lock`: cross-process serialization of setup/repair/maintenance/launch transition.
- `/proc` exact `WINEPREFIX` inspection: maintenance is deferred while user-facing prefix processes are active.
- launch path rechecks RSI/Star Citizen after acquiring the stack lock, closing the double-click race.
- `maintenance.lock`: cross-process serialization of setup/repair/maintenance/launch transitions.
- exact `/proc` `WINEPREFIX` inspection: maintenance is deferred while RSI/Star Citizen/Wine tools use the prefix.
- launch path rechecks RSI/Star Citizen after taking the stack lock.
## GUI
## Security boundary
The UI is embedded in the static binary. It binds an ephemeral port on `127.0.0.1` and uses a cryptographically random per-process route token. No remote web content is needed. A second `citizen-launcher gui` discovers the locked instance and reopens that URL instead of spawning another backend.
## Distribution boundary
Citizen Launcher manages Wine/DXVK/RSI in XDG user directories. It does not replace distribution GPU drivers, kernels or package update policy. Debian packaging supplies only the system limits and a narrowly scoped package self-update timer; interactive system preparation uses Polkit rather than passwordless sudo.
Citizen Launcher never creates passwordless sudo rules. Interactive privileged preparation uses Polkit/pkexec. Base-system upgrades, GPU drivers, kernel and firmware remain under the distribution's own update mechanism.
+52 -10
View File
@@ -1,15 +1,57 @@
# Distribution support
Citizen Launcher deliberately avoids distro-specific Wine packages so the same tested gaming stack can run across distributions.
Citizen Launcher keeps Wine, DXVK and RSI in the user account and deliberately avoids distro Wine packages. Native packages are thin integration layers around the same static Go core.
| Distribution family | Primary install | Background maintenance | Status |
|---|---|---|---|
| Debian 13 / Ubuntu / Mint | `.deb` | systemd user stack timer + system package self-update timer | primary |
| Fedora | generic binary / `install.sh` (RPM spec included) | systemd user timer | supported core |
| Arch Linux / Omarchy | `install.sh` | systemd user timer | supported core; optional Omarchy adapter |
| openSUSE | generic binary / `install.sh` | systemd user timer | supported core |
| other amd64 desktop Linux | generic binary | systemd timer when available | best effort |
## Supported families
Runtime requirements are an x86-64 CPU with AVX, a real Vulkan-capable GPU/driver, a Linux-native executable filesystem for the prefix, sufficient RAM+swap and storage, and basic desktop utilities. Citizen Launcher checks these before mutating the game stack.
| Family | Examples | Preferred package | Automatic launcher update | Support level |
|---|---|---|---|---|
| Debian/Ubuntu | Debian 13+, Ubuntu 24.04/26.04, Mint, Pop!_OS, Zorin, TUXEDO OS | `.deb` | APT via verified system timer | primary |
| Fedora/RHEL | Fedora 43+, Nobara, Rocky/Alma/RHEL derivatives | `.rpm` | verified RPM via system timer | primary core |
| Arch | Arch, Manjaro, EndeavourOS, CachyOS, Garuda, Omarchy | `.pkg.tar.zst` | pacman via verified system timer | primary core |
| openSUSE | Tumbleweed, Slowroll; Leap where current dependencies are available | `.rpm` | verified RPM via system timer | primary core |
| Immutable desktops | Fedora Silverblue/Kinoite/Bazzite, SteamOS, openSUSE Aeon/MicroOS | `install.sh` user mode | verified user binary update | supported without mutating base image |
| Other glibc x86-64 desktop Linux | Gentoo, Void/glibc, custom distributions | `install.sh` / generic tarball | verified user binary update | best effort |
The launcher detects common package managers for diagnostics but does not silently run full distribution upgrades or replace GPU/kernel packages.
The game still requires an x86-64 CPU with AVX, a real Vulkan-capable GPU, sufficient RAM+swap/storage, a Linux-native executable filesystem and a Wine runner compatible with the host glibc/CPU. Citizen Launcher validates those conditions before changing the game stack.
## Package-manager mapping
Runtime detection uses `/etc/os-release` first and then the native package manager:
- Debian family → `apt`
- Fedora family → `dnf5` / `dnf`
- Arch family → `pacman`
- SUSE family → `zypper`
- Fedora Atomic/OSTree → `rpm-ostree` (base-image updates remain external)
- openSUSE transactional variants → `transactional-update` (base-image updates remain external)
Foreign package tools in `$PATH` do not override the detected distro family.
## One-command project installer
From an extracted project/release bundle:
```bash
./INSTALLIEREN.sh
```
It prefers the native package when it is present in `dist/`. On immutable systems, or when a native package is not available, it intentionally falls back to the user installation instead of modifying the base OS outside its package manager.
## Native package build inputs
- Debian: `packaging/build-deb.sh`
- Fedora/openSUSE RPM: `packaging/build-rpm.sh`
- Arch/pacman: `packaging/build-arch.sh`
- Generic Linux: `packaging/build-tarball.sh`
- Available formats on the current build host: `packaging/build-all.sh`
Gitea Actions builds RPM and Arch packages inside native Fedora and Arch container jobs on CI/release runs.
## Immutable distributions
Citizen Launcher never runs `rpm -U`/`pacman -U` against an immutable base image. When an installed system package is detected on an immutable host, the launcher reports that the host image/package layer owns that update. A `~/.local` user installation remains fully self-updateable from the verified generic tarball.
## Not a base-system updater
Citizen Launcher does not silently upgrade kernels, Mesa/NVIDIA drivers, firmware or the whole Linux distribution. Those remain owned by APT/DNF/pacman/zypper/rpm-ostree/transactional-update. This avoids partial upgrades and keeps rollback/recovery in the distribution's control.
+72
View File
@@ -0,0 +1,72 @@
# Gitea Actions setup
Citizen Launcher 1.1.1 uses Gitea Actions natively. Workflows are in `.gitea/workflows/`; the old `.github/workflows/` directory is intentionally absent.
## Requirements
- Gitea Actions enabled for the repository.
- An `act_runner` with an `ubuntu-latest` label.
- Docker-capable runner execution, because Fedora and Arch package jobs use job containers.
- Outbound HTTPS access for Go modules, upstream package metadata and the referenced `actions/checkout@v4` / `actions/setup-go@v5` actions.
Gitea Actions is GitHub-Actions compatible enough to run those standard actions. By default Gitea resolves `actions/...` from GitHub. An installation that wants to avoid that external dependency can mirror the two actions into its own Gitea and configure the instance's default actions URL accordingly.
## CI
`.gitea/workflows/ci.yml` runs on pushes and pull requests:
- full regression/race/package verification on the Ubuntu runner;
- RPM build and inspection inside Fedora;
- pacman package build as an unprivileged user inside Arch Linux.
If your runner uses a different label, replace `runs-on: ubuntu-latest` in both workflow files.
## Releases
Push a version tag matching `VERSION`, for example:
```bash
git tag v1.1.1
git push origin v1.1.1
```
`.gitea/workflows/release.yml` then:
1. verifies tag ↔ `VERSION`;
2. creates (or reuses on rerun) the Gitea Release;
3. builds Debian/generic, Fedora RPM and Arch packages;
4. uploads each package directly to the Gitea Release;
5. downloads the release packages again and creates a deterministic `SHA256SUMS.txt`;
6. uploads the checksum manifest.
The workflow uses Gitea's built-in job token with:
```yaml
permissions:
code: read
releases: write
```
No personal access token and no `gh` CLI are needed.
## Self-update source
Release builds set:
```text
gitea:<GITEA_API_URL>/repos/<owner>/<repo>
```
inside `/etc/citizen-launcher/release-repo`. Example:
```text
gitea:https://git.example.org/api/v1/repos/games/citizen-launcher
```
Citizen Launcher resolves `/releases/latest`, reads the matching release package and uses `SHA256SUMS.txt` to obtain its SHA-256 before any native package update. Privileged Gitea update sources must be HTTPS.
Existing `github:owner/repo` and legacy `owner/repo` sources remain supported.
## Reruns
The release helper `scripts/gitea-release.sh` is intentionally rerun-safe. Existing same-named attachments are removed before a replacement is uploaded, so rerunning a failed release does not create duplicate package assets.
+74 -53
View File
@@ -1,62 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")"
PLUGIN_ID="local.omarchy-citizen"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
DEST="$HOME/.config/omarchy/plugins/$PLUGIN_ID"
. /etc/os-release 2>/dev/null || true
id="${ID:-linux}"
like=" ${ID_LIKE:-} "
variant="${VARIANT_ID:-}"
immutable=false
case "$id:$variant" in
steamos:*|*:*silverblue*|*:*kinoite*|*:*sericea*|*:*onyx*|*:*atomic*|opensuse-microos:*|aeon:*|kalpa:*) immutable=true ;;
esac
[[ -e /run/ostree-booted ]] && immutable=true
message() {
local title="$1"
local body="$2"
if command -v zenity >/dev/null 2>&1; then
zenity --info --width=500 --title="$title" --text="$body" >/dev/null 2>&1 || true
elif 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
root_run() {
if [[ $EUID -eq 0 ]]; then "$@"; return; fi
if command -v pkexec >/dev/null 2>&1; then pkexec "$@"; return; fi
if command -v sudo >/dev/null 2>&1; then sudo "$@"; return; fi
echo "Für eine native Paketinstallation werden Administratorrechte benötigt." >&2
return 1
}
if ! command -v omarchy >/dev/null 2>&1; then
message "Omarchy Citizen" "Omarchy wurde nicht gefunden. Dieses Plugin benötigt Omarchy Quattro."
exit 1
family=other
case "$id" in
debian|ubuntu|linuxmint|pop|elementary|zorin|kali|neon|tuxedo) family=debian ;;
fedora|nobara|rhel|centos|rocky|almalinux|ultramarine) family=fedora ;;
arch|manjaro|endeavouros|cachyos|garuda|steamos) family=arch ;;
opensuse|opensuse-tumbleweed|opensuse-leap|opensuse-slowroll|sles|sled) family=suse ;;
*)
[[ "$like" == *" debian "* || "$like" == *" ubuntu "* ]] && family=debian
[[ "$like" == *" fedora "* || "$like" == *" rhel "* ]] && family=fedora
[[ "$like" == *" arch "* ]] && family=arch
[[ "$like" == *" suse "* || "$like" == *" opensuse "* ]] && family=suse
;;
esac
if $immutable; then
echo "Immutable Linux-Variante erkannt ($id${variant:+/$variant})."
echo "Citizen Launcher wird sicher im Benutzerkonto installiert; das Basis-Image bleibt unangetastet."
exec "$ROOT/install.sh"
fi
mkdir -p "$DEST"
case "$family" in
debian)
pkg="$ROOT/dist/citizen-launcher_${VERSION}_amd64.deb"
if [[ -s "$pkg" ]] && command -v apt-get >/dev/null 2>&1; then
echo "Debian/Ubuntu-Familie erkannt – installiere natives .deb …"
root_run apt-get install -y "$pkg"
exit
fi
;;
fedora|suse)
pkg="$ROOT/dist/citizen-launcher-${VERSION}-1.linux.x86_64.rpm"
if [[ -s "$pkg" ]] && command -v rpm >/dev/null 2>&1; then
echo "RPM-Familie erkannt – installiere natives RPM …"
if [[ "$family" == fedora ]] && { command -v dnf5 >/dev/null 2>&1 || command -v dnf >/dev/null 2>&1; }; then
pm=dnf; command -v dnf5 >/dev/null 2>&1 && pm=dnf5
root_run "$pm" -y install "$pkg"
elif command -v zypper >/dev/null 2>&1; then
# Install stable dependencies through the distro solver first. The local
# RPM itself is integrity-checked by the release digest during updates.
root_run zypper --non-interactive install ca-certificates tar curl unzip xz polkit util-linux || true
root_run rpm -Uvh --replacepkgs "$pkg"
else
root_run rpm -Uvh --replacepkgs "$pkg"
fi
exit
fi
;;
arch)
pkg="$ROOT/dist/citizen-launcher-${VERSION}-1-x86_64.pkg.tar.zst"
if [[ -s "$pkg" ]] && command -v pacman >/dev/null 2>&1; then
echo "Arch-Familie erkannt – installiere natives pacman-Paket …"
root_run pacman -U --noconfirm "$pkg"
exit
fi
;;
esac
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/install.sh" "$DEST/uninstall.sh" "$DEST/INSTALLIEREN.sh" "$DEST/backend/bin/omarchy-citizen-backend"
if ! omarchy plugin validate "$DEST" >/tmp/omarchy-citizen-plugin-validation.log 2>&1; then
message "Omarchy Citizen" "Das Plugin konnte nicht validiert werden. Details stehen in /tmp/omarchy-citizen-plugin-validation.log"
exit 1
fi
omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true
"$DEST/backend/bin/omarchy-citizen-backend" self-sync >/dev/null 2>&1 || true
"$HOME/.local/lib/omarchy-citizen/omarchy-citizen-backend" install-service >/dev/null 2>&1 || true
omarchy plugin enable "$PLUGIN_ID" >/dev/null 2>&1 || true
"$HOME/.local/lib/omarchy-citizen/omarchy-citizen-backend" autopilot enable >/tmp/omarchy-citizen-autopilot-install.log 2>&1 || true
omarchy bar move "$PLUGIN_ID" --section right >/dev/null 2>&1 || true
# QML hot-reload is currently unreliable for third-party bar plugins.
omarchy restart shell >/dev/null 2>&1 || true
message "Omarchy Citizen" "Installation abgeschlossen.
Oben in der Omarchy-Leiste findest du jetzt ✦SC.
Klicke darauf und anschließend auf „EINRICHTEN & STARTKLAR MACHEN“."
echo "Kein passendes natives Paket im Projektordner gefunden."
echo "Installiere die portable, vollständig updatefähige Benutzer-Version …"
exec "$ROOT/install.sh"
+101 -41
View File
@@ -1,61 +1,107 @@
# Citizen Launcher 1.0.1
# Citizen Launcher 1.1.1
Citizen Launcher is a distro-neutral Star Citizen setup, launch, repair and maintenance application for Linux. Omarchy support is optional; the core is one static Go binary shared by Debian/Ubuntu, Fedora, Arch/Omarchy and other desktop distributions.
Citizen Launcher is a cross-distribution Star Citizen setup, launch, repair and maintenance application for Linux. The same static Go core runs on Debian/Ubuntu, Fedora/RHEL derivatives, Arch derivatives, openSUSE and generic glibc-based desktop Linux. Omarchy support is an optional integration, not a runtime requirement.
## Product goal
**Install Citizen Launcher → click setup → log into RSI → install/play Star Citizen.**
The launcher owns the fragile user-space gaming stack so users do not have to pick Wine builds, copy DXVK DLLs or rebuild prefixes by hand. Kernel, GPU driver and base-distribution updates remain owned by the Linux distribution.
The launcher owns the fragile user-space gaming stack so users do not have to pick Wine builds, copy DXVK DLLs or rebuild prefixes by hand. Kernel, GPU driver, firmware and base-distribution upgrades remain owned by the Linux distribution.
## What 1.0 manages
The 1.0.1 gaming path was confirmed end-to-end on real hardware: launcher, RSI installation and Star Citizen playability. 1.1.1 keeps that gaming core and adds native multi-distribution packaging/update integration.
- hardware/Vulkan/AVX/RAM/storage/filesystem preflight
- Linux `vm.max_map_count` and file-limit preparation
- newest **locally compatible** stable LUG Wine runner with rollback retention
- isolated Wine self-tests before runner activation
- deterministic, SHA-256-pinned Winetricks base setup
- verified portable PowerShell Core + RSI-compatible Wine wrapper (no fragile PowerShell MSI install)
- DXVK download, digest verification, installation and native DLL overrides
- RSI `latest.yml`, SHA-512 verified installer download and launcher repair
- Star Citizen desktop entry and stable launch path
- single-instance GUI, stack-operation locking and duplicate RSI/Star Citizen launch prevention
- automatic gaming-stack maintenance
- automatic verified Debian package self-updates
- privacy-conscious support bundle and rotating logs
- migration from older Omarchy Citizen / user-local installations
## Supported Linux families
## Install on Debian / Ubuntu / Mint
- Debian / Ubuntu / Mint / Pop!_OS / Zorin / TUXEDO OS → `.deb`
- Fedora / Nobara / RHEL-family → `.rpm`
- Arch / Manjaro / EndeavourOS / CachyOS / Garuda / Omarchy → `.pkg.tar.zst`
- openSUSE Tumbleweed / Slowroll → `.rpm`
- immutable Fedora/SteamOS/openSUSE variants → safe `~/.local` user install
- other glibc x86-64 desktops → generic tarball / user installer
Install the release `.deb`:
See `DISTRO_SUPPORT.md` for the detailed matrix and immutable-system behavior.
## Easiest install from the project bundle
```bash
sudo apt install ./citizen-launcher_1.0.1_amd64.deb
./INSTALLIEREN.sh
```
The package adds the desktop application and enables the system package-update timer. Future Citizen Launcher `.deb` releases can be installed automatically after release-asset digest and package metadata verification.
The installer detects the distro family, prefers a native package found in `dist/`, and falls back to the portable user installation when that is safer or no native package is present.
## Generic desktop Linux install
### Debian / Ubuntu / Mint
```bash
sudo apt install ./dist/citizen-launcher_1.1.1_amd64.deb
```
### Fedora / Nobara / RHEL family
```bash
sudo dnf install ./dist/citizen-launcher-1.1.1-1.linux.x86_64.rpm
```
### openSUSE Tumbleweed / Slowroll
```bash
sudo zypper install ./dist/citizen-launcher-1.1.1-1.linux.x86_64.rpm
```
### Arch / Manjaro / EndeavourOS / CachyOS / Omarchy
```bash
sudo pacman -U ./dist/citizen-launcher-1.1.1-1-x86_64.pkg.tar.zst
```
### Generic / immutable desktop Linux
```bash
./install.sh
```
or use the release tarball. Fedora/Arch/openSUSE users can use the generic build; an RPM spec is included for packaging work.
For Omarchy plus the optional bar widget:
```bash
./install-omarchy.sh
```
## What Citizen Launcher manages
- hardware/Vulkan/AVX/RAM/storage/filesystem preflight
- `vm.max_map_count` and file-limit preparation
- newest **locally compatible** stable LUG Wine runner with rollback retention
- isolated Wine self-tests before activation
- deterministic checksum-pinned Winetricks base setup
- verified portable PowerShell Core + RSI-compatible Wine wrapper
- DXVK download, verification, installation and native DLL overrides
- RSI `latest.yml`, SHA-512 verified installer download and repair
- Star Citizen desktop integration and stable launch path
- single-instance GUI, operation locking and duplicate game/launcher prevention
- automatic gaming-stack maintenance
- verified native package self-updates on mutable Debian/RPM/Arch systems
- safe user-binary updates on immutable/generic systems
- privacy-conscious support bundle and rotating logs
## Native package updates
The system package timer only updates **Citizen Launcher itself**. It never performs a full distro upgrade.
Package selection is tied to the installed package database:
- DPKG package → `.deb` → APT
- RPM package → `.rpm` → RPM database
- pacman package → `.pkg.tar.zst` → pacman
- user installation → generic tarball → atomic user update
Before privileged installation, the release asset SHA-256 and package name/version/architecture are verified. GitHub sources can use the API digest; Gitea sources use the release `SHA256SUMS.txt` generated by the release workflow. Immutable base systems are excluded from direct system-package self-updates.
## GUI
```bash
citizen-launcher gui
```
The GUI is embedded in the binary and served only on `127.0.0.1` behind a random per-process route. Chromium-family browsers open it as an app window; otherwise the default browser is used. A file lock guarantees a single GUI backend. Opening Citizen Launcher again reuses the existing instance.
The GUI is embedded in the binary and served only on `127.0.0.1` behind a random per-process route. A file lock guarantees one GUI backend per user; opening Citizen Launcher again reuses the existing instance.
## Data locations
@@ -65,25 +111,39 @@ The GUI is embedded in the binary and served only on `127.0.0.1` behind a random
- `~/.cache/citizen-launcher`
- default Wine prefix: `~/Games/star-citizen`
The uninstaller intentionally preserves the game prefix, game files, configuration and support logs unless the user removes them separately.
Game data is deliberately preserved during launcher uninstall/reinstall.
## Build and verification
## Build
```bash
./build.sh
./packaging/build-all.sh
```
Individual native builders:
```bash
./packaging/build-deb.sh
./packaging/build-rpm.sh
./packaging/build-arch.sh
./packaging/build-tarball.sh
```
RPM and Arch packages are built in native Fedora and Arch containers by Gitea Actions. Tag releases are published directly through the Gitea REST API; no `gh` CLI or GitHub release token is required.
## Gitea Actions
CI/CD lives in `.gitea/workflows/`. `ci.yml` runs the regression suite plus native Fedora/Arch package builds. `release.yml` reacts to `v*` tags, creates the Gitea Release through the built-in job token, uploads `.deb`, `.rpm`, `.pkg.tar.zst` and the generic tarball, then publishes `SHA256SUMS.txt`.
Packages produced by the Gitea release workflow embed that Gitea instance/repository as their self-update source, so moving CI to Gitea does not leave runtime updates pointing back to GitHub. See `GITEA.md`.
## Verification
```bash
./tests/full-verify.sh
```
The full gate includes shell syntax, gofmt cleanliness, unit/regression tests, `go vet`, static amd64 build, Go race detector, Omarchy update integration test, Debian package build/metadata/payload verification and generic tarball verification.
The release gate covers shell syntax, gofmt, Go unit/regression tests, `go vet`, static amd64 build, race detector, Omarchy integration, Debian payload verification and static validation of all native packaging definitions. Release CI additionally builds and inspects RPM and pacman packages inside their native distro environments.
## Security / reliability boundaries
- no passwordless sudo rules are created
- executable release assets fail closed when their expected digest is unavailable
- RSI installer is checked against its published SHA-512 metadata
- Winetricks and the portable RSI PowerShell compatibility assets are version-pinned and checksum-verified
- Wine/DXVK archives are extracted with path/symlink traversal checks
- privileged Debian self-update accepts only the configured release repository, expected package name/version/architecture and verified SHA-256 asset digest
- maintenance never rewrites the active Wine prefix while RSI Launcher/Star Citizen is using it
- incomplete prefixes are preserved instead of blindly deleted
See `ARCHITECTURE.md`, `DISTRO_SUPPORT.md` and `RELEASE_NOTES.md` for details.
See `ARCHITECTURE.md`, `DISTRO_SUPPORT.md`, `packaging/SELF_UPDATE.md` and `RELEASE_NOTES.md` for details.
+19 -22
View File
@@ -1,31 +1,28 @@
# Citizen Launcher 1.0.1
# Citizen Launcher 1.1.1
Reliability hotfix based on the first real-machine 1.0.0 acceptance bundle.
## Gitea-native CI/CD release
## Fixed
1.1.1 keeps the confirmed playable multi-distribution 1.1.x runtime and migrates the project automation from GitHub Actions to Gitea Actions.
- Vulkan hardware detection now evaluates individual `vulkaninfo --summary` GPU blocks instead of treating the presence of any software ICD as a software-only system.
- A real AMD/NVIDIA/Intel Vulkan GPU is accepted even when Mesa also exposes `llvmpipe`/lavapipe/softpipe.
- Discrete GPUs are preferred for display/status, then integrated GPUs, then non-software virtual GPUs.
- QEMU/bochs remains blocked when it is the only usable graphics path, but no longer blocks a machine that also exposes a real working Vulkan GPU.
- Vulkan API compatibility is evaluated on the selected real GPU rather than the first arbitrary device in the summary.
- PowerShell compatibility self-test no longer requires stdout to be relayed through Wine. PowerShell Core is tested directly and the RSI-compatible wrapper is tested by its propagated exit status.
- Game/support status now discovers Prefix, DXVK, PowerShell, RSI Launcher and game files before applying an overall hardware/preflight block. A blocked machine therefore no longer reports existing components as falsely `missing`.
### Gitea workflows
## Regression coverage
- workflows now live exclusively under `.gitea/workflows/`
- CI keeps the full Go/race/package verification plus Fedora RPM and Arch package builds
- tag releases are created with Gitea's REST API and built-in `GITEA_TOKEN`
- release publishing no longer depends on the `gh` CLI
- release jobs upload `.deb`, `.rpm`, `.pkg.tar.zst`, generic tarball and one combined `SHA256SUMS.txt`
- upload is idempotent: a rerun replaces same-named release attachments instead of duplicating them
- AMD RADV + llvmpipe must select AMD and report Vulkan ready.
- llvmpipe-only must remain rejected.
- a successful PowerShell wrapper with empty stdout must pass.
- blocked overall health must still expose real installed component states.
- existing 1.0 release-gate coverage remains active: race detector, stack/GUI locks, desktop-entry repair, safe archive extraction, self-update validation, package validation and integration tests.
### Gitea-aware self-update
## Upgrade
Packages produced by Gitea Actions embed an exact `gitea:<api-repository-url>` update source. The launcher can now resolve Gitea's latest-release API and hydrate per-asset SHA-256 values from the workflow-generated `SHA256SUMS.txt`. GitHub release sources remain supported for existing installations.
Debian/Ubuntu/Mint:
For privileged updates, a configured Gitea source must use HTTPS. This preserves the fail-closed package-update model.
```bash
sudo apt install ./citizen-launcher_1.0.1_amd64.deb
```
### Runner portability
Existing configuration, Wine prefix, RSI Launcher and Star Citizen data are preserved.
The release flow does not depend on cross-job `upload-artifact` compatibility. Each native build uploads its package directly to the Gitea Release, and the final job downloads those release attachments to create the checksum manifest. This works across a wider range of act_runner versions.
### Runtime
Wine, DXVK, RSI Launcher setup, hardware checks, single-instance protection, repair, support bundles and the already confirmed playable Star Citizen path are unchanged.
+47 -26
View File
@@ -1,49 +1,70 @@
# Citizen Launcher 1.0.1 – Verification Report
# Citizen Launcher 1.1.1 – Verification Report
Date: 2026-08-31
Date: 2026-09-01
## Release gate
## Release scope
The 1.0.1 source tree passed the complete automated release gate:
Citizen Launcher 1.1.1 keeps the confirmed playable 1.1.x multi-distribution gaming core and migrates CI/CD and release publishing to Gitea Actions.
- shell syntax checks for installer, uninstaller, packaging and integration scripts
## Automated release gate
The 1.1.1 tree passes the local release gate with:
- shell syntax checks for installers, packaging, Gitea release helper and tests
- `gofmt` cleanliness
- `go test ./...`
- `go vet ./...`
- static Linux amd64 build
- static Linux amd64 build and version check
- `go test -race ./...`
- Omarchy updater integration test against disposable local Git repositories
- Debian package build and metadata/payload verification
- generic Linux amd64 tarball build and execution check
- systemd service/timer syntax verification
- desktop entry regression checks
- AppStream and workflow validation inherited from the 1.0 release gate
- Gitea workflow YAML parsing
- assertion that legacy `.github/workflows/` does not shadow `.gitea/workflows/`
- assertion that the release workflow does not use `gh release` or cross-job artifact actions
- mocked Gitea REST API test covering release creation, release update on rerun, asset upload, same-name asset replacement and asset download
- Gitea release-source parsing and `SHA256SUMS.txt` parser regression tests
- static validation of RPM spec and Arch PKGBUILD/package hooks
- multi-distro platform-family and immutable-host regression tests
- native package self-update parser/asset-selection tests for DEB, RPM and pacman formats
## Real-machine regressions reproduced from the support bundle
## Gitea CI/CD design verified
The uploaded Debian 13 support bundle exposed two 1.0.0 false negatives:
Workflows live in:
1. `vulkaninfo` reported both `AMD Radeon Graphics (RADV PHOENIX2)` and Mesa `llvmpipe`. 1.0.0 rejected the whole system merely because the software ICD was present. 1.0.1 parses devices independently and selects the real AMD GPU.
2. The RSI PowerShell wrapper returned a successful process exit but no captured stdout. 1.0.0 incorrectly required a marker string in stdout. 1.0.1 validates PowerShell Core and then treats the wrapper's propagated exit status as authoritative.
- `.gitea/workflows/ci.yml`
- `.gitea/workflows/release.yml`
Automated regression tests cover both conditions.
The release workflow uses Gitea-native contexts (`gitea.api_url`, `gitea.repository`, `gitea.ref_name`, `gitea.sha`, `gitea.token`) and the built-in job token with `code: read` / `releases: write` permissions.
## Additional process-level verification
Release packages are uploaded directly to the Gitea Release rather than moved between jobs with `actions/upload-artifact`. The final job downloads the native release packages and publishes one deterministic `SHA256SUMS.txt`.
A real two-process GUI test was run against the built binary:
The release helper is rerun-safe: an existing release is refreshed and same-named attachments are replaced instead of duplicated.
- first `gui --no-open` created a localhost endpoint
- second `gui --no-open` returned the exact same endpoint instead of creating another backend
- `/api/ping` on that endpoint reported version `1.0.1`
## Gitea-aware launcher self-update
A synthetic `vulkaninfo` run matching the support bundle's AMD + llvmpipe layout selected `AMD Radeon Graphics (RADV PHOENIX2)` and reported Vulkan `ready`. The sandbox itself has insufficient RAM for Star Citizen, so its overall hardware state remained blocked for RAM as expected; the GPU path was no longer the blocker.
A Gitea workflow build injects this form as the launcher's trusted update source:
## Final binary / package hashes
```text
gitea:<gitea.api_url>/repos/<owner>/<repo>
```
- `backend/bin/citizen-launcher`: `7cd0d85046b2c9ac295c6ef5b86f93a3452bfb3cd94cff048e7511db581f3d07`
- `dist/citizen-launcher_1.0.1_amd64.deb`: `aba0944a0d2334f14f425e12a55ddd28d011449387e5be1ef3e0731e9d9a5119`
- `dist/citizen-launcher-1.0.1-linux-amd64.tar.gz`: `59edf81938c305b1d86d917d38222470b4f5e42c9c3dc350bf1ac430d8748bfc`
The launcher resolves Gitea's latest release API and maps the release `SHA256SUMS.txt` back onto the package assets before a privileged update is allowed. Privileged Gitea sources require HTTPS. Existing GitHub release-source syntax remains supported for migration/backward compatibility.
## Scope boundary
A separate build check confirmed that a `gitea:https://.../api/v1/repos/owner/repo` source is successfully embedded into the static binary and reported by `self-update status`.
The release gate validates the launcher, package, updater, GUI, locks, migration, install/repair logic and local safety properties. It cannot perform an RSI account login or a complete live Star Citizen game session from this sandbox. Those remain real-machine acceptance tests.
## Native package build coverage
The current local build environment contains Debian packaging tools, therefore these artifacts can be built and inspected locally:
- `dist/citizen-launcher_1.1.1_amd64.deb`
- `dist/citizen-launcher-1.1.1-linux-amd64.tar.gz`
The local environment does not provide native `rpmbuild` / Arch `makepkg`; the Gitea workflows build those in Fedora and Arch job containers:
- `citizen-launcher-1.1.1-1.linux.x86_64.rpm`
- `citizen-launcher-1.1.1-1-x86_64.pkg.tar.zst`
## Gaming-core acceptance
The Star Citizen install/play path is unchanged from the already accepted 1.0.1/1.1.0 line. The CI migration changes release and update plumbing, not Wine/DXVK/RSI launch behavior.
+1 -1
View File
@@ -1 +1 @@
1.0.1
1.1.1
Binary file not shown.
+1 -1
View File
@@ -3,7 +3,7 @@ set -euo pipefail
ROOT="$(cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$(dirname "$0")"
VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")"
RELEASE_REPO="${CITIZEN_LAUNCHER_RELEASE_REPO:-sendnwv/omarchy-sc}"
RELEASE_REPO="${CITIZEN_LAUNCHER_RELEASE_REPO:-github:sendnwv/omarchy-sc}"
mkdir -p bin
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-trimpath -ldflags="-s -w -X main.appVersion=$VERSION -X main.releaseRepo=$RELEASE_REPO" \
+14 -9
View File
@@ -23,14 +23,16 @@ import (
var guiFiles embed.FS
type GUIStatus struct {
Version string `json:"version"`
InstalledVersion string `json:"installed_version,omitempty"`
RestartRequired bool `json:"restart_required"`
PackageAutoUpdate bool `json:"package_auto_update"`
Platform PlatformStatus `json:"platform"`
Game GameStatus `json:"game"`
Launcher Status `json:"launcher"`
ActiveJob *GUIJob `json:"active_job,omitempty"`
Version string `json:"version"`
InstalledVersion string `json:"installed_version,omitempty"`
RestartRequired bool `json:"restart_required"`
PackageAutoUpdate bool `json:"package_auto_update"`
LauncherAutoUpdate bool `json:"launcher_auto_update"`
SelfUpdateMode string `json:"self_update_mode"`
Platform PlatformStatus `json:"platform"`
Game GameStatus `json:"game"`
Launcher Status `json:"launcher"`
ActiveJob *GUIJob `json:"active_job,omitempty"`
}
type GUIJob struct {
@@ -250,8 +252,11 @@ func (a *App) runGUI(args []string) error {
mux.HandleFunc(base+"/api/status", func(w http.ResponseWriter, r *http.Request) {
st, _ := a.status(false)
su, _ := a.selfUpdateStatus(false)
packageAuto := packageAutoUpdateActive()
launcherAuto := packageAuto || (su.Mode == "user" && st.AutoMaintain)
jsonOut(w, GUIStatus{Version: appVersion, InstalledVersion: su.Installed, RestartRequired: su.RestartNeeded,
PackageAutoUpdate: packageAutoUpdateActive(), Platform: detectPlatform(), Game: a.gameStatus(), Launcher: st, ActiveJob: jobs.active()})
PackageAutoUpdate: packageAuto, LauncherAutoUpdate: launcherAuto, SelfUpdateMode: su.Mode,
Platform: detectPlatform(), Game: a.gameStatus(), Launcher: st, ActiveJob: jobs.active()})
})
mux.HandleFunc(base+"/api/job/", func(w http.ResponseWriter, r *http.Request) {
j := jobs.get(strings.TrimPrefix(r.URL.Path, base+"/api/job/"))
+2 -2
View File
@@ -96,8 +96,8 @@ func (a *App) starDesktopPath() string {
return filepath.Join(a.userApplicationsDir(), "citizen-launcher-star-citizen.desktop")
}
// migrateUserInstall removes the old ~/.local shadow install when a Debian
// package is now authoritative. It only removes files that identify themselves
// migrateUserInstall removes an old ~/.local shadow install when a native
// system package is now authoritative. It only removes files that identify themselves
// as Citizen Launcher and are not newer than the installed package.
func (a *App) migrateUserInstall() {
if os.Geteuid() == 0 {
+6 -3
View File
@@ -16,8 +16,8 @@ import (
)
var (
appVersion = "1.0.1"
releaseRepo = "sendnwv/omarchy-sc"
appVersion = "1.1.1"
releaseRepo = "github:sendnwv/omarchy-sc"
)
const (
@@ -423,7 +423,7 @@ func (a *App) tick() error {
problems = append(problems, "gaming stack: "+err.Error())
}
// Generic ~/.local installations can update atomically in the user account.
// Debian packages are updated by the root system timer installed by the .deb.
// Native system packages are updated by the root package timer; user installs update atomically in the user account.
if su, err := a.selfUpdateStatus(true); err != nil {
a.logf("launcher release check warning: %v", err)
} else if su.State == "available" && su.Mode == "user" {
@@ -881,11 +881,14 @@ 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("variant=%s\n", p.Variant)
fmt.Printf("id_like=%s\n", p.IDLike)
fmt.Printf("family=%s\n", p.Family)
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("immutable=%t\n", p.Immutable)
fmt.Printf("omarchy=%t\n", p.Omarchy)
}
+136 -35
View File
@@ -11,55 +11,71 @@ type PlatformStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Variant string `json:"variant,omitempty"`
IDLike string `json:"id_like,omitempty"`
Family string `json:"family"`
PackageManager string `json:"package_manager"`
Desktop string `json:"desktop,omitempty"`
Session string `json:"session,omitempty"`
SystemdUser bool `json:"systemd_user"`
Immutable bool `json:"immutable"`
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), `"'`)
}
vals := readOSRelease("/etc/os-release")
p := platformFromOSRelease(vals)
// Prefer the package manager that belongs to the detected distro family. This
// avoids mis-detection on systems that happen to have foreign packaging tools
// installed for development or compatibility purposes.
switch p.Family {
case "debian":
if commandExists("apt-get") {
p.PackageManager = "apt"
}
if vals["ID"] != "" {
p.ID = vals["ID"]
case "fedora":
if commandExists("rpm-ostree") && p.Immutable {
p.PackageManager = "rpm-ostree"
} else if commandExists("dnf5") {
p.PackageManager = "dnf5"
} else if commandExists("dnf") {
p.PackageManager = "dnf"
}
if vals["PRETTY_NAME"] != "" {
p.Name = vals["PRETTY_NAME"]
} else if vals["NAME"] != "" {
p.Name = vals["NAME"]
case "arch":
if commandExists("pacman") {
p.PackageManager = "pacman"
}
case "suse":
if commandExists("transactional-update") && p.Immutable {
p.PackageManager = "transactional-update"
} else if commandExists("zypper") {
p.PackageManager = "zypper"
}
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"
if p.PackageManager == "" {
switch {
case commandExists("apt-get"):
p.PackageManager = "apt"
case commandExists("dnf5"):
p.PackageManager = "dnf5"
case commandExists("dnf"):
p.PackageManager = "dnf"
case commandExists("pacman"):
p.PackageManager = "pacman"
case commandExists("zypper"):
p.PackageManager = "zypper"
case commandExists("xbps-install"):
p.PackageManager = "xbps"
case commandExists("emerge"):
p.PackageManager = "portage"
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") {
@@ -69,3 +85,88 @@ func detectPlatform() PlatformStatus {
p.Omarchy = commandExists("omarchy")
return p
}
func readOSRelease(path string) map[string]string {
vals := map[string]string{}
f, err := os.Open(path)
if err != nil {
return vals
}
defer f.Close()
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), `"'`)
}
}
return vals
}
func platformFromOSRelease(vals map[string]string) PlatformStatus {
p := PlatformStatus{ID: "linux", Name: "Linux", Family: "other"}
if vals["ID"] != "" {
p.ID = strings.ToLower(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.Variant = vals["VARIANT_ID"]
p.IDLike = strings.ToLower(vals["ID_LIKE"])
p.Family = distroFamily(p.ID, p.IDLike)
p.Immutable = immutableDistro(p.ID, p.Variant) || fileExists("/run/ostree-booted") || fileExists("/run/transactional-update")
return p
}
func distroFamily(id, idLike string) string {
id = strings.ToLower(id)
like := " " + strings.ToLower(idLike) + " "
containsLike := func(v string) bool { return strings.Contains(like, " "+v+" ") }
switch id {
case "debian", "ubuntu", "linuxmint", "pop", "elementary", "zorin", "kali", "neon", "tuxedo":
return "debian"
case "fedora", "nobara", "rhel", "centos", "rocky", "almalinux", "ultramarine":
return "fedora"
case "arch", "manjaro", "endeavouros", "cachyos", "garuda", "steamos":
return "arch"
case "opensuse", "opensuse-tumbleweed", "opensuse-leap", "opensuse-slowroll", "sles", "sled":
return "suse"
}
switch {
case containsLike("debian") || containsLike("ubuntu"):
return "debian"
case containsLike("fedora") || containsLike("rhel") || containsLike("centos"):
return "fedora"
case containsLike("arch"):
return "arch"
case containsLike("suse") || containsLike("opensuse"):
return "suse"
default:
return "other"
}
}
func immutableDistro(id, variant string) bool {
id = strings.ToLower(id)
variant = strings.ToLower(variant)
if id == "steamos" || strings.Contains(id, "microos") || strings.Contains(id, "aeon") || strings.Contains(id, "kalpa") {
return true
}
switch variant {
case "silverblue", "kinoite", "sericea", "onyx", "atomic", "coreos", "steamdeck":
return true
}
return false
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
@@ -0,0 +1,46 @@
package main
import "testing"
func TestDistroFamily(t *testing.T) {
cases := []struct {
id, like, want string
}{
{"debian", "", "debian"},
{"ubuntu", "debian", "debian"},
{"linuxmint", "ubuntu debian", "debian"},
{"fedora", "", "fedora"},
{"nobara", "fedora", "fedora"},
{"rocky", "rhel centos fedora", "fedora"},
{"arch", "", "arch"},
{"manjaro", "arch", "arch"},
{"omarchy", "arch", "arch"},
{"opensuse-tumbleweed", "suse opensuse", "suse"},
{"opensuse-leap", "suse opensuse", "suse"},
{"custom", "debian", "debian"},
{"gentoo", "", "other"},
}
for _, tc := range cases {
if got := distroFamily(tc.id, tc.like); got != tc.want {
t.Fatalf("distroFamily(%q,%q)=%q want %q", tc.id, tc.like, got, tc.want)
}
}
}
func TestImmutableDistro(t *testing.T) {
for _, tc := range []struct {
id, variant string
}{
{"fedora", "silverblue"},
{"fedora", "kinoite"},
{"steamos", "steamdeck"},
{"opensuse-microos", ""},
} {
if !immutableDistro(tc.id, tc.variant) {
t.Fatalf("expected immutable: %#v", tc)
}
}
if immutableDistro("fedora", "workstation") {
t.Fatal("Fedora Workstation must not be treated as immutable")
}
}
+393 -75
View File
@@ -3,9 +3,12 @@ package main
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -30,6 +33,11 @@ type SelfUpdateStatus struct {
Error string `json:"error,omitempty"`
}
type installedPackage struct {
Mode string
Version string
}
func packageAutoUpdateActive() bool {
if !commandExists("systemctl") {
return false
@@ -37,9 +45,52 @@ func packageAutoUpdateActive() bool {
return exec.Command("systemctl", "is-enabled", "citizen-launcher-self-update.timer").Run() == nil
}
var releaseRepoPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
var releaseRepoPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$`)
func validReleaseRepo(v string) bool { return releaseRepoPattern.MatchString(strings.TrimSpace(v)) }
type launcherReleaseSource struct {
Kind string
Display string
GitHubRepo string
APIRepoURL string
}
func parseLauncherReleaseSource(v string) (launcherReleaseSource, bool) {
v = strings.TrimSpace(v)
if releaseRepoPattern.MatchString(v) {
return launcherReleaseSource{Kind: "github", Display: v, GitHubRepo: v}, true
}
if strings.HasPrefix(v, "github:") {
repo := strings.TrimSpace(strings.TrimPrefix(v, "github:"))
if releaseRepoPattern.MatchString(repo) {
return launcherReleaseSource{Kind: "github", Display: "github:" + repo, GitHubRepo: repo}, true
}
return launcherReleaseSource{}, false
}
if strings.HasPrefix(v, "gitea:") {
raw := strings.TrimSpace(strings.TrimPrefix(v, "gitea:"))
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return launcherReleaseSource{}, false
}
clean := strings.TrimRight(u.String(), "/")
// Store the exact Gitea API repository endpoint so installations also work
// when the Gitea instance itself is hosted below a URL sub-path.
if !strings.Contains(u.Path, "/api/v1/repos/") {
return launcherReleaseSource{}, false
}
tail := strings.Trim(strings.SplitN(u.Path, "/api/v1/repos/", 2)[1], "/")
if !releaseRepoPattern.MatchString(tail) {
return launcherReleaseSource{}, false
}
return launcherReleaseSource{Kind: "gitea", Display: "gitea:" + clean, APIRepoURL: clean}, true
}
return launcherReleaseSource{}, false
}
func validReleaseRepo(v string) bool {
_, ok := parseLauncherReleaseSource(v)
return ok
}
func effectiveReleaseRepo() string {
// Never let a user-controlled environment variable redirect the privileged
@@ -67,16 +118,128 @@ func effectiveReleaseRepo() string {
if validReleaseRepo(releaseRepo) {
return releaseRepo
}
return "sendnwv/omarchy-sc"
return "github:sendnwv/omarchy-sc"
}
func launcherLatestRelease(spec string) (githubRelease, error) {
source, ok := parseLauncherReleaseSource(spec)
if !ok {
return githubRelease{}, fmt.Errorf("invalid launcher release source %q", spec)
}
var rel githubRelease
var err error
switch source.Kind {
case "github":
rel, err = githubLatest(source.GitHubRepo)
case "gitea":
req, reqErr := http.NewRequest("GET", source.APIRepoURL+"/releases/latest", nil)
if reqErr != nil {
return githubRelease{}, reqErr
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 30 * time.Second}
resp, doErr := client.Do(req)
if doErr != nil {
return githubRelease{}, doErr
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return githubRelease{}, fmt.Errorf("Gitea release API returned HTTP %d", resp.StatusCode)
}
err = json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&rel)
default:
err = fmt.Errorf("unsupported release source %q", source.Kind)
}
if err != nil {
return githubRelease{}, err
}
// Gitea release attachments do not currently expose GitHub's per-asset
// digest field. Release workflows publish SHA256SUMS.txt, which is resolved
// here into the same digest field used by the fail-closed updater.
if err := hydrateReleaseChecksums(&rel); err != nil {
return githubRelease{}, err
}
return rel, nil
}
func hydrateReleaseChecksums(rel *githubRelease) error {
if rel == nil {
return errors.New("nil release")
}
checksumURL := ""
for _, asset := range rel.Assets {
if asset.Name == "SHA256SUMS.txt" {
checksumURL = asset.BrowserDownloadURL
break
}
}
if checksumURL == "" {
return nil
}
req, err := http.NewRequest("GET", checksumURL, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("release checksum download returned HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return err
}
checksums := parseSHA256SUMS(string(data))
for i := range rel.Assets {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rel.Assets[i].Digest)), "sha256:") {
continue
}
if sum := checksums[rel.Assets[i].Name]; sum != "" {
rel.Assets[i].Digest = "sha256:" + sum
}
}
return nil
}
func parseSHA256SUMS(body string) map[string]string {
out := map[string]string{}
hex64 := regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 || !hex64.MatchString(fields[0]) {
continue
}
name := strings.TrimPrefix(fields[len(fields)-1], "*")
name = strings.TrimPrefix(name, "./")
if filepath.Base(name) != name || name == "" {
continue
}
out[name] = strings.ToLower(fields[0])
}
return out
}
func (a *App) selfUpdateStatus(fetch bool) (SelfUpdateStatus, error) {
installed := installedPackageVersion()
pkg := installedPackageInfo()
mode := a.installMode()
installed := pkg.Version
if mode == "user" {
installed = executableVersion(a.selfPath)
}
st := SelfUpdateStatus{
Current: appVersion,
Installed: installed,
State: "not-checked",
Mode: a.installMode(),
Mode: mode,
Repository: effectiveReleaseRepo(),
}
if installed != "" && compareVersions(installed, appVersion) > 0 {
@@ -89,7 +252,7 @@ func (a *App) selfUpdateStatus(fetch bool) (SelfUpdateStatus, error) {
return st, nil
}
release, err := githubLatest(effectiveReleaseRepo())
release, err := launcherLatestRelease(effectiveReleaseRepo())
st.CheckedAt = time.Now().Format(time.RFC3339)
if err != nil {
st.State = "check-failed"
@@ -104,7 +267,27 @@ func (a *App) selfUpdateStatus(fetch bool) (SelfUpdateStatus, error) {
}
st.Latest = latest
asset, err := a.releaseAssetForMode(release, latest, st.Mode)
current := appVersion
if installed != "" && compareVersions(installed, current) > 0 {
current = installed
}
cmp := compareVersions(latest, current)
// Immutable system images must be updated by their host image/package layer.
// A ~/.local installation on the same machine still uses mode=user and can
// update itself atomically without touching the immutable base system.
if mode == "system-managed" {
if cmp > 0 {
st.State = "external-update"
} else if st.RestartNeeded {
st.State = "restart-required"
} else {
st.State = "current"
}
return st, nil
}
asset, err := a.releaseAssetForMode(release, latest, mode)
if err != nil {
st.State = "asset-missing"
st.Error = err.Error()
@@ -113,11 +296,6 @@ func (a *App) selfUpdateStatus(fetch bool) (SelfUpdateStatus, error) {
st.Asset = asset.Name
st.Digest = asset.Digest
current := appVersion
if installed != "" && compareVersions(installed, current) > 0 {
current = installed
}
cmp := compareVersions(latest, current)
switch {
case cmp > 0:
st.State = "available"
@@ -130,52 +308,129 @@ func (a *App) selfUpdateStatus(fetch bool) (SelfUpdateStatus, error) {
}
func (a *App) installMode() string {
if installedPackageVersion() != "" && commandExists("dpkg-deb") {
return "deb"
pkg := installedPackageInfo()
if pkg.Version == "" || !isSystemExecutable(a.selfPath) {
return "user"
}
return "user"
p := detectPlatform()
if p.Immutable {
return "system-managed"
}
return pkg.Mode
}
func installedPackageVersion() string {
if !commandExists("dpkg-query") {
func isSystemExecutable(path string) bool {
if path == "" {
return false
}
clean, _ := filepath.EvalSymlinks(path)
if clean == "" {
clean = filepath.Clean(path)
}
return clean == "/usr/bin/citizen-launcher" || clean == "/bin/citizen-launcher"
}
func executableVersion(path string) string {
if path == "" {
return ""
}
cmd := exec.Command("dpkg-query", "-W", "-f=${Status}\n${Version}", "citizen-launcher")
out, err := cmd.CombinedOutput()
out, err := exec.Command(path, "--version").CombinedOutput()
if err != nil {
return ""
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 || !strings.Contains(lines[0], "install ok installed") {
return ""
return strings.TrimSpace(string(out))
}
func installedPackageVersion() string { return installedPackageInfo().Version }
func installedPackageInfo() installedPackage {
// Prefer the package database native to /etc/os-release. This prevents a
// developer-installed foreign package tool from taking ownership of updates.
vals := readOSRelease("/etc/os-release")
family := distroFamily(strings.ToLower(vals["ID"]), strings.ToLower(vals["ID_LIKE"]))
order := []string{"deb", "rpm", "arch"}
switch family {
case "debian":
order = []string{"deb", "rpm", "arch"}
case "fedora", "suse":
order = []string{"rpm", "deb", "arch"}
case "arch":
order = []string{"arch", "rpm", "deb"}
}
return strings.TrimSpace(lines[len(lines)-1])
for _, mode := range order {
if pkg := queryInstalledPackage(mode); pkg.Version != "" {
return pkg
}
}
return installedPackage{}
}
func queryInstalledPackage(mode string) installedPackage {
switch mode {
case "deb":
if !commandExists("dpkg-query") {
return installedPackage{}
}
cmd := exec.Command("dpkg-query", "-W", "-f=${Status}\n${Version}", "citizen-launcher")
if out, err := cmd.CombinedOutput(); err == nil {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) >= 2 && strings.Contains(lines[0], "install ok installed") {
return installedPackage{Mode: "deb", Version: strings.TrimSpace(lines[len(lines)-1])}
}
}
case "rpm":
if !commandExists("rpm") {
return installedPackage{}
}
cmd := exec.Command("rpm", "-q", "--qf", "%{VERSION}\n", "citizen-launcher")
if out, err := cmd.CombinedOutput(); err == nil {
if v := strings.TrimSpace(string(out)); v != "" {
return installedPackage{Mode: "rpm", Version: v}
}
}
case "arch":
if !commandExists("pacman") {
return installedPackage{}
}
cmd := exec.Command("pacman", "-Q", "citizen-launcher")
if out, err := cmd.CombinedOutput(); err == nil {
fields := strings.Fields(strings.TrimSpace(string(out)))
if len(fields) >= 2 && fields[0] == "citizen-launcher" {
return installedPackage{Mode: "arch", Version: fields[1]}
}
}
}
return installedPackage{}
}
func (a *App) releaseAssetForMode(release githubRelease, version, mode string) (githubAsset, error) {
if mode == "deb" {
want := "citizen-launcher_" + version + "_amd64.deb"
for _, asset := range release.Assets {
if asset.Name == want {
return asset, nil
}
}
// Accept Debian revisions such as 0.9.2-1 while keeping the package name strict.
re := regexp.MustCompile(`^citizen-launcher_` + regexp.QuoteMeta(version) + `(?:-[0-9]+)?_amd64\.deb$`)
patterns := map[string][]*regexp.Regexp{
"deb": {
regexp.MustCompile(`^citizen-launcher_` + regexp.QuoteMeta(version) + `_amd64\.deb$`),
regexp.MustCompile(`^citizen-launcher_` + regexp.QuoteMeta(version) + `-[0-9]+_amd64\.deb$`),
},
"rpm": {
regexp.MustCompile(`^citizen-launcher-` + regexp.QuoteMeta(version) + `-[0-9]+(?:\.[A-Za-z0-9_.-]+)?\.x86_64\.rpm$`),
},
"arch": {
regexp.MustCompile(`^citizen-launcher-` + regexp.QuoteMeta(version) + `-[0-9]+-x86_64\.pkg\.tar\.(?:zst|xz|gz)$`),
},
"user": {
regexp.MustCompile(`^citizen-launcher-` + regexp.QuoteMeta(version) + `-linux-amd64\.tar\.gz$`),
},
}
res, ok := patterns[mode]
if !ok {
return githubAsset{}, fmt.Errorf("release updates are not supported for install mode %q", mode)
}
for _, re := range res {
for _, asset := range release.Assets {
if re.MatchString(asset.Name) {
return asset, nil
}
}
return githubAsset{}, fmt.Errorf("release %s has no amd64 Debian package", release.TagName)
}
want := "citizen-launcher-" + version + "-linux-amd64.tar.gz"
for _, asset := range release.Assets {
if asset.Name == want {
return asset, nil
}
}
return githubAsset{}, fmt.Errorf("release %s has no generic amd64 tarball", release.TagName)
return githubAsset{}, fmt.Errorf("release %s has no %s package for x86-64", release.TagName, mode)
}
func (a *App) applySelfUpdate(system, quiet bool) error {
@@ -185,15 +440,22 @@ func (a *App) applySelfUpdate(system, quiet bool) error {
}
if st.State == "current" || st.State == "restart-required" {
if !quiet {
fmt.Printf("Citizen Launcher %s ist bereits installiert.\n", st.Installed)
shown := st.Installed
if shown == "" {
shown = appVersion
}
fmt.Printf("Citizen Launcher %s ist bereits installiert.\n", shown)
}
return nil
}
if st.State == "external-update" || st.Mode == "system-managed" {
return errors.New("Dieses immutable Linux-System verwaltet /usr über sein System-Image. Bitte Citizen Launcher über rpm-ostree/transactional-update bzw. die Distribution aktualisieren; eine ~/.local-Installation kann sich weiterhin selbst aktualisieren")
}
if st.State != "available" {
return fmt.Errorf("self-update is not applicable in state %q", st.State)
}
release, err := githubLatest(effectiveReleaseRepo())
release, err := launcherLatestRelease(effectiveReleaseRepo())
if err != nil {
return err
}
@@ -205,13 +467,13 @@ func (a *App) applySelfUpdate(system, quiet bool) error {
return err
}
if !strings.HasPrefix(strings.ToLower(asset.Digest), "sha256:") {
return errors.New("release asset has no GitHub SHA-256 digest; refusing automatic update")
return errors.New("release asset has no SHA-256 digest/checksum; refusing automatic update")
}
if st.Mode == "deb" {
if st.Mode == "deb" || st.Mode == "rpm" || st.Mode == "arch" {
if !system || os.Geteuid() != 0 {
if commandExists("pkexec") {
self := a.selfPath
self := a.stableExecutable()
if self == "" {
self = "/usr/bin/citizen-launcher"
}
@@ -219,14 +481,14 @@ func (a *App) applySelfUpdate(system, quiet bool) error {
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return cmd.Run()
}
return errors.New("Debian package update needs root privileges and pkexec is unavailable")
return fmt.Errorf("%s package update needs root privileges and pkexec is unavailable", st.Mode)
}
return a.applyDebUpdate(asset, st.Latest, quiet)
return a.applyNativePackageUpdate(st.Mode, asset, st.Latest, quiet)
}
return a.applyUserUpdate(asset, st.Latest, quiet)
}
func (a *App) applyDebUpdate(asset githubAsset, version string, quiet bool) error {
func (a *App) applyNativePackageUpdate(mode string, asset githubAsset, version string, quiet bool) error {
cache := "/var/cache/citizen-launcher"
if err := os.MkdirAll(cache, 0o755); err != nil {
return err
@@ -241,34 +503,53 @@ func (a *App) applyDebUpdate(asset githubAsset, version string, quiet bool) erro
_ = os.Remove(tmp)
return err
}
if err := verifyDebPackage(tmp, version); err != nil {
_ = os.Remove(tmp)
return err
switch mode {
case "deb":
if err := verifyDebPackage(tmp, version); err != nil {
_ = os.Remove(tmp)
return err
}
case "rpm":
if err := verifyRPMPackage(tmp, version); err != nil {
_ = os.Remove(tmp)
return err
}
case "arch":
if err := verifyArchPackage(tmp, version); err != nil {
_ = os.Remove(tmp)
return err
}
default:
return fmt.Errorf("unsupported native package mode %q", mode)
}
if err := os.Rename(tmp, path); err != nil {
return err
}
cmd := exec.Command("apt-get",
"-o", "DPkg::Lock::Timeout=120",
"-o", "Dpkg::Options::=--force-confold",
"install", "-y", "--no-install-recommends", path,
)
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
var cmd *exec.Cmd
switch mode {
case "deb":
cmd = exec.Command("apt-get", "-o", "DPkg::Lock::Timeout=120", "-o", "Dpkg::Options::=--force-confold", "install", "-y", "--no-install-recommends", path)
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
case "rpm":
// The RPM has already been authenticated through the configured release
// source's SHA-256 metadata and its package metadata is checked above. rpm still performs
// dependency and scriptlet validation locally.
cmd = exec.Command("rpm", "-Uvh", "--replacepkgs", path)
case "arch":
cmd = exec.Command("pacman", "-U", "--noconfirm", "--needed", path)
}
if quiet {
if os.Geteuid() == 0 {
// The package updater runs with ProtectHome=true. Log to the systemd
// journal instead of trying to write below /root.
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
} else {
cmd.Stdout = a.logWriter()
cmd.Stderr = a.logWriter()
cmd.Stdout, cmd.Stderr = a.logWriter(), a.logWriter()
}
} else {
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
}
if err := cmd.Run(); err != nil {
return fmt.Errorf("APT package update failed: %w", err)
return fmt.Errorf("%s package update failed: %w", mode, err)
}
installed := installedPackageVersion()
if compareVersions(installed, version) < 0 {
@@ -296,22 +577,59 @@ func verifyDebPackage(path, version string) error {
values[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
pkg, pkgVersion, arch := values["Package"], values["Version"], values["Architecture"]
if pkg == "" || pkgVersion == "" || arch == "" {
return fmt.Errorf("unexpected Debian package metadata: %q", strings.TrimSpace(string(out)))
return verifyPackageIdentity(values["Package"], values["Version"], values["Architecture"], version, "amd64")
}
func verifyRPMPackage(path, version string) error {
if !commandExists("rpm") {
return errors.New("rpm is unavailable")
}
if pkg != "citizen-launcher" {
return fmt.Errorf("refusing package %q", pkg)
cmd := exec.Command("rpm", "-qp", "--qf", "%{NAME}\n%{VERSION}\n%{ARCH}\n", path)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("cannot inspect RPM package: %s", formatCommandFailure(err, out))
}
versionOK := pkgVersion == version
if !versionOK {
rev := regexp.MustCompile(`^` + regexp.QuoteMeta(version) + `-[0-9]+$`)
versionOK = rev.MatchString(pkgVersion)
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 3 {
return fmt.Errorf("unexpected RPM package metadata: %q", strings.TrimSpace(string(out)))
}
if !versionOK {
return fmt.Errorf("package version %q does not match release %q", pkgVersion, version)
return verifyPackageIdentity(strings.TrimSpace(lines[0]), strings.TrimSpace(lines[1]), strings.TrimSpace(lines[2]), version, "x86_64")
}
func verifyArchPackage(path, version string) error {
tool := ""
if commandExists("bsdtar") {
tool = "bsdtar"
} else if commandExists("tar") {
tool = "tar"
} else {
return errors.New("tar/bsdtar is unavailable")
}
if arch != "amd64" {
out, err := exec.Command(tool, "-xOf", path, ".PKGINFO").CombinedOutput()
if err != nil {
return fmt.Errorf("cannot inspect Arch package: %s", formatCommandFailure(err, out))
}
vals := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
k, v, ok := strings.Cut(line, "=")
if ok {
vals[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
return verifyPackageIdentity(vals["pkgname"], vals["pkgver"], vals["arch"], version, "x86_64")
}
func verifyPackageIdentity(name, pkgVersion, arch, releaseVersion, wantArch string) error {
if name == "" || pkgVersion == "" || arch == "" {
return fmt.Errorf("package metadata is incomplete (name=%q version=%q arch=%q)", name, pkgVersion, arch)
}
if name != "citizen-launcher" {
return fmt.Errorf("refusing package %q", name)
}
if compareVersions(pkgVersion, releaseVersion) != 0 {
return fmt.Errorf("package version %q does not match release %q", pkgVersion, releaseVersion)
}
if arch != wantArch {
return fmt.Errorf("refusing architecture %q", arch)
}
return nil
@@ -322,7 +640,7 @@ func (a *App) applyUserUpdate(asset githubAsset, version string, quiet bool) err
return errors.New("cannot locate running Citizen Launcher executable")
}
if !strings.HasPrefix(strings.ToLower(asset.Digest), "sha256:") {
return errors.New("release asset has no GitHub SHA-256 digest; refusing automatic update")
return errors.New("release asset has no SHA-256 digest/checksum; refusing automatic update")
}
if err := os.MkdirAll(a.cacheDir, 0o755); err != nil {
return err
@@ -85,3 +85,108 @@ func TestVerifyDebPackageRejectsMismatchedNewerVersion(t *testing.T) {
t.Fatal("mismatched package version was accepted")
}
}
func TestReleaseAssetsForAllNativeModes(t *testing.T) {
a, _ := newApp()
r := githubRelease{TagName: "v1.1.1", Assets: []githubAsset{
{Name: "citizen-launcher_1.1.1_amd64.deb"},
{Name: "citizen-launcher-1.1.1-1.linux.x86_64.rpm"},
{Name: "citizen-launcher-1.1.1-1-x86_64.pkg.tar.zst"},
{Name: "citizen-launcher-1.1.1-linux-amd64.tar.gz"},
}}
want := map[string]string{
"deb": "citizen-launcher_1.1.1_amd64.deb",
"rpm": "citizen-launcher-1.1.1-1.linux.x86_64.rpm",
"arch": "citizen-launcher-1.1.1-1-x86_64.pkg.tar.zst",
"user": "citizen-launcher-1.1.1-linux-amd64.tar.gz",
}
for mode, name := range want {
asset, err := a.releaseAssetForMode(r, "1.1.1", mode)
if err != nil {
t.Fatalf("%s: %v", mode, err)
}
if asset.Name != name {
t.Fatalf("%s: got %q want %q", mode, asset.Name, name)
}
}
}
func TestVerifyPackageIdentityAcceptsNativeReleaseSuffixes(t *testing.T) {
cases := []struct{ version, arch, wantArch string }{
{"1.1.0", "amd64", "amd64"},
{"1.1.0-1", "x86_64", "x86_64"},
{"1.1.0", "x86_64", "x86_64"},
}
for _, tc := range cases {
if err := verifyPackageIdentity("citizen-launcher", tc.version, tc.arch, "1.1.0", tc.wantArch); err != nil {
t.Fatal(err)
}
}
if err := verifyPackageIdentity("evil", "1.1.0", "x86_64", "1.1.0", "x86_64"); err == nil {
t.Fatal("wrong package name accepted")
}
}
func TestNativePackageDatabaseParsers(t *testing.T) {
root := t.TempDir()
write := func(name, body string) {
p := filepath.Join(root, name)
if err := os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
}
write("dpkg-query", "printf 'install ok installed\\n1.1.0\\n'")
write("rpm", "printf '1.1.0\\n'")
write("pacman", "printf 'citizen-launcher 1.1.0-1\\n'")
t.Setenv("PATH", root)
for mode, want := range map[string]string{"deb": "1.1.0", "rpm": "1.1.0", "arch": "1.1.0-1"} {
pkg := queryInstalledPackage(mode)
if pkg.Mode != mode || pkg.Version != want {
t.Fatalf("%s parser: %#v want version %s", mode, pkg, want)
}
}
}
func TestParseLauncherReleaseSource(t *testing.T) {
cases := []struct {
in, kind string
ok bool
}{
{"owner/repo", "github", true},
{"github:owner/repo", "github", true},
{"gitea:https://git.example.test/api/v1/repos/owner/repo", "gitea", true},
{"gitea:https://git.example.test/sub/path/api/v1/repos/owner/repo", "gitea", true},
{"gitea:https://git.example.test/api/v1/repos/owner/repo/", "gitea", true},
{"gitea:http://git.example.test/api/v1/repos/owner/repo", "", false},
{"gitea:https://user:pass@git.example.test/api/v1/repos/owner/repo", "", false},
{"gitea:https://git.example.test/owner/repo", "", false},
{"../evil", "", false},
}
for _, tc := range cases {
src, ok := parseLauncherReleaseSource(tc.in)
if ok != tc.ok {
t.Fatalf("parseLauncherReleaseSource(%q) ok=%v want %v", tc.in, ok, tc.ok)
}
if ok && src.Kind != tc.kind {
t.Fatalf("parseLauncherReleaseSource(%q) kind=%q want %q", tc.in, src.Kind, tc.kind)
}
}
}
func TestParseSHA256SUMS(t *testing.T) {
body := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa citizen-launcher_1.1.1_amd64.deb\n" +
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb *citizen-launcher-1.1.1-linux-amd64.tar.gz\n" +
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc ../escape.deb\n" +
"not-a-hash ignored\n"
got := parseSHA256SUMS(body)
if got["citizen-launcher_1.1.1_amd64.deb"] != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
t.Fatal("deb checksum missing")
}
if got["citizen-launcher-1.1.1-linux-amd64.tar.gz"] != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" {
t.Fatal("tarball checksum missing")
}
if _, ok := got["escape.deb"]; ok {
t.Fatal("path-traversal checksum entry accepted")
}
}
+2 -2
View File
@@ -560,8 +560,8 @@ func (a *App) ensureLUGRuntime() (string, error) {
}
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.
// Prefer ordinary distro tools. Native packages request these where practical,
// while the portable toolbox remains a cross-distro fallback.
required := []string{"cabextract", "curl", "unzip"}
allSystem := true
for _, tool := range required {
+1 -1
View File
@@ -6,7 +6,7 @@ function primaryFor(h){if(h==='ready')return['STAR CITIZEN STARTEN','launch'];if
function setStep(name,state){const e=document.querySelector(`[data-step="${name}"]`);if(!e)return;e.dataset.state=state}
function renderSteps(g){setStep('system',g.hardware==='blocked'||g.system?.state==='blocked'?'bad':g.system?.state==='prepare'?'warn':'good');setStep('wine',g.wine_version&&g.powershell_state==='ready'?'good':g.wine_version?'warn':'todo');setStep('launcher',g.launcher_state==='ready'?'good':'todo');setStep('game',g.game_state==='ready'?'good':g.game_state==='partial'?'warn':'todo')}
function setButtonsBusy(busy){document.querySelectorAll('button[data-action]').forEach(b=>{if(b.dataset.action!=='restart')b.disabled=busy});$('primary').disabled=busy}
async function refresh(){try{const s=await api('/api/status');current=s;activeJob=s.active_job||null;$('version').textContent='v'+s.version;$('platform').textContent=[s.platform.name,s.platform.desktop].filter(Boolean).join(' · ');$('distro').textContent=s.platform.name;$('session').textContent=s.platform.session||'—';txt('packageUpdates',s.package_auto_update?'automatisch':'manuell',s.package_auto_update?'good':'warn');const ub=$('updateBanner');if(s.restart_required){ub.classList.remove('hidden');$('updateText').textContent='Installiert ist '+(s.installed_version||'eine neuere Version')+', dieses Fenster läuft noch mit '+s.version+'.'}else ub.classList.add('hidden');const g=s.game;$('hero').dataset.health=g.health||'setup';let head='READY FOR SETUP',sub='Citizen Launcher übernimmt Wine, DXVK, Prefix und RSI Launcher.';if(g.health==='ready'){head='FLIGHT READY';sub='Star Citizen ist startbereit. Autopilot hält den Gaming-Stack aktuell.'}else if(g.health==='install-game'){head='RSI LAUNCHER READY';sub='Melde dich im RSI Launcher an und installiere Star Citizen am vorgegebenen C:-Pfad.'}else if(g.health==='hardware-blocked'){head='SYSTEM NICHT SPIELBEREIT';sub=g.hardware_reason||g.system?.reason||'Eine Voraussetzung ist noch nicht erfüllt.'}else if(g.health==='system-prepare'){head='SYSTEMVORBEREITUNG';sub='Citizen Launcher kann die benötigten Linux-Limits automatisch setzen.'}else if(g.health==='repair'||g.health==='launcher-repair'){head='REPAIR AVAILABLE';sub='Ein reparierbarer Zustand wurde erkannt. Spieldaten werden nicht gelöscht.'}$('headline').textContent=head;$('subline').textContent=sub;const [label,act]=primaryFor(g.health);$('primary').textContent=label;$('primary').dataset.action=act;renderSteps(g);txt('gpu',g.gpu||'nicht eindeutig erkannt',g.hardware==='blocked'?'bad':'good');txt('vulkan',g.vulkan,g.vulkan==='blocked'?'bad':g.vulkan==='ready'?'good':'warn');txt('memory',`${g.ram_gib} GiB RAM · ${g.combined_gib} GiB gesamt`,g.combined_gib>=48?'good':'warn');txt('disk',(g.system?.storage_free_gib??g.disk_free_gib)+' GiB',((g.system?.storage_free_gib??g.disk_free_gib)>=150||g.game_state==='ready')?'good':'warn');txt('filesystem',g.system?.filesystem||'unbekannt',g.system?.filesystem_ok===false?'bad':'good');txt('wine',g.wine_version||'wird eingerichtet',g.wine_version?'good':'warn');txt('dxvk',g.dxvk_state==='ready'?(g.dxvk_version||'bereit'):g.dxvk_state==='repair'?((g.dxvk_version||'DXVK')+' · Reparatur nötig'):'wird eingerichtet',g.dxvk_state==='ready'?'good':g.dxvk_state==='repair'?'warn':'warn');txt('powershell',g.powershell_state==='ready'?'bereit':g.prefix_state==='ready'?'Reparatur nötig':'wird eingerichtet',g.powershell_state==='ready'?'good':g.prefix_state==='ready'?'warn':'');txt('rsi',g.launcher_state==='ready'?'bereit':'Setup',g.launcher_state==='ready'?'good':'warn');txt('game',g.game_state==='ready'?'LIVE bereit':g.game_state==='partial'?'teilweise vorhanden':'noch nicht installiert',g.game_state==='ready'?'good':g.game_state==='partial'?'warn':'');txt('prefix',g.prefix||'—');txt('autopilot',g.autopilot?'aktiv':'aus',g.autopilot?'good':'warn');$('systemPill').textContent=g.hardware==='blocked'||g.system?.state==='blocked'?'BLOCKED':g.system?.state==='prepare'?'SETUP':'READY';$('systemPill').className='pill '+(g.hardware==='blocked'||g.system?.state==='blocked'?'bad':g.system?.state==='prepare'?'warn':'good');const stackReady=g.launcher_state==='ready'&&g.dxvk_state==='ready'&&g.powershell_state==='ready';$('stackPill').textContent=stackReady?'READY':'SETUP';$('stackPill').className='pill '+(stackReady?'good':'warn');$('autoPill').textContent=g.autopilot?'AUTO':'OFF';$('autoPill').className='pill '+(g.autopilot?'good':'warn');$('raw').textContent=JSON.stringify(s,null,2);setButtonsBusy(!!activeJob);if(activeJob&&$('job').classList.contains('hidden')){showJob('running',friendlyAction(activeJob.action),'Wird ausgeführt …');poll(activeJob.id)}}catch(e){$('headline').textContent='BACKEND ERROR';$('subline').textContent=e.message}}
async function refresh(){try{const s=await api('/api/status');current=s;activeJob=s.active_job||null;$('version').textContent='v'+s.version;$('platform').textContent=[s.platform.name,s.platform.desktop].filter(Boolean).join(' · ');$('distro').textContent=s.platform.name;$('session').textContent=s.platform.session||'—';const updateMode=({deb:'APT',rpm:'RPM',arch:'pacman',user:'Benutzer', 'system-managed':'System-Image'})[s.self_update_mode]||s.self_update_mode||'';txt('packageUpdates',s.launcher_auto_update?('automatisch'+(updateMode?' · '+updateMode:'')):((s.self_update_mode==='system-managed'?'über System-Image':'manuell')+(updateMode&&s.self_update_mode!=='system-managed'?' · '+updateMode:'')),s.launcher_auto_update?'good':s.self_update_mode==='system-managed'?'good':'warn');const ub=$('updateBanner');if(s.restart_required){ub.classList.remove('hidden');$('updateText').textContent='Installiert ist '+(s.installed_version||'eine neuere Version')+', dieses Fenster läuft noch mit '+s.version+'.'}else ub.classList.add('hidden');const g=s.game;$('hero').dataset.health=g.health||'setup';let head='READY FOR SETUP',sub='Citizen Launcher übernimmt Wine, DXVK, Prefix und RSI Launcher.';if(g.health==='ready'){head='FLIGHT READY';sub='Star Citizen ist startbereit. Autopilot hält den Gaming-Stack aktuell.'}else if(g.health==='install-game'){head='RSI LAUNCHER READY';sub='Melde dich im RSI Launcher an und installiere Star Citizen am vorgegebenen C:-Pfad.'}else if(g.health==='hardware-blocked'){head='SYSTEM NICHT SPIELBEREIT';sub=g.hardware_reason||g.system?.reason||'Eine Voraussetzung ist noch nicht erfüllt.'}else if(g.health==='system-prepare'){head='SYSTEMVORBEREITUNG';sub='Citizen Launcher kann die benötigten Linux-Limits automatisch setzen.'}else if(g.health==='repair'||g.health==='launcher-repair'){head='REPAIR AVAILABLE';sub='Ein reparierbarer Zustand wurde erkannt. Spieldaten werden nicht gelöscht.'}$('headline').textContent=head;$('subline').textContent=sub;const [label,act]=primaryFor(g.health);$('primary').textContent=label;$('primary').dataset.action=act;renderSteps(g);txt('gpu',g.gpu||'nicht eindeutig erkannt',g.hardware==='blocked'?'bad':'good');txt('vulkan',g.vulkan,g.vulkan==='blocked'?'bad':g.vulkan==='ready'?'good':'warn');txt('memory',`${g.ram_gib} GiB RAM · ${g.combined_gib} GiB gesamt`,g.combined_gib>=48?'good':'warn');txt('disk',(g.system?.storage_free_gib??g.disk_free_gib)+' GiB',((g.system?.storage_free_gib??g.disk_free_gib)>=150||g.game_state==='ready')?'good':'warn');txt('filesystem',g.system?.filesystem||'unbekannt',g.system?.filesystem_ok===false?'bad':'good');txt('wine',g.wine_version||'wird eingerichtet',g.wine_version?'good':'warn');txt('dxvk',g.dxvk_state==='ready'?(g.dxvk_version||'bereit'):g.dxvk_state==='repair'?((g.dxvk_version||'DXVK')+' · Reparatur nötig'):'wird eingerichtet',g.dxvk_state==='ready'?'good':g.dxvk_state==='repair'?'warn':'warn');txt('powershell',g.powershell_state==='ready'?'bereit':g.prefix_state==='ready'?'Reparatur nötig':'wird eingerichtet',g.powershell_state==='ready'?'good':g.prefix_state==='ready'?'warn':'');txt('rsi',g.launcher_state==='ready'?'bereit':'Setup',g.launcher_state==='ready'?'good':'warn');txt('game',g.game_state==='ready'?'LIVE bereit':g.game_state==='partial'?'teilweise vorhanden':'noch nicht installiert',g.game_state==='ready'?'good':g.game_state==='partial'?'warn':'');txt('prefix',g.prefix||'—');txt('autopilot',g.autopilot?'aktiv':'aus',g.autopilot?'good':'warn');$('systemPill').textContent=g.hardware==='blocked'||g.system?.state==='blocked'?'BLOCKED':g.system?.state==='prepare'?'SETUP':'READY';$('systemPill').className='pill '+(g.hardware==='blocked'||g.system?.state==='blocked'?'bad':g.system?.state==='prepare'?'warn':'good');const stackReady=g.launcher_state==='ready'&&g.dxvk_state==='ready'&&g.powershell_state==='ready';$('stackPill').textContent=stackReady?'READY':'SETUP';$('stackPill').className='pill '+(stackReady?'good':'warn');$('autoPill').textContent=g.autopilot?'AUTO':'OFF';$('autoPill').className='pill '+(g.autopilot?'good':'warn');$('raw').textContent=JSON.stringify(s,null,2);setButtonsBusy(!!activeJob);if(activeJob&&$('job').classList.contains('hidden')){showJob('running',friendlyAction(activeJob.action),'Wird ausgeführt …');poll(activeJob.id)}}catch(e){$('headline').textContent='BACKEND ERROR';$('subline').textContent=e.message}}
function friendlyAction(a){return({setup:'Einrichtung',repair:'Automatische Reparatur',maintain:'Gaming-Stack aktualisieren',doctor:'Wine-Selbsttest',support:'Support-Paket', 'prepare-system':'Systemvorbereitung','choose-prefix':'Installationsordner'})[a]||'Aktion'}
function showJob(state,title,message,raw=''){const box=$('job');box.classList.remove('hidden','error','done');if(state==='error')box.classList.add('error');if(state==='done')box.classList.add('done');$('jobTitle').textContent=title;$('jobText').textContent=message||'';const spin=box.querySelector('.spinner'),symbol=$('jobSymbol');spin.classList.toggle('hidden',state!=='running');symbol.classList.toggle('hidden',state==='running');symbol.textContent=state==='done'?'✓':'!';const details=$('jobDetails');if(raw&&raw!==message){details.classList.remove('hidden');$('jobRaw').textContent=raw}else{details.classList.add('hidden');$('jobRaw').textContent=''}}
async function action(name){try{if(name==='restart'){await api('/api/action/restart',{method:'POST'});showJob('done','Launcher wird neu gestartet','Die neue Version wird geöffnet …');return}if(name==='launch'){const r=await api('/api/action/launch',{method:'POST'});if(r.state==='game-running'){showJob('done','Star Citizen läuft bereits','Es wurde keine zweite Instanz gestartet.')}else if(r.state==='launcher-running'){showJob('done','RSI Launcher läuft bereits','Es wurde keine zweite Instanz gestartet.')}else{showJob('done','RSI Launcher gestartet','Der Launcher wurde gestartet.')}return}const j=await api('/api/action/'+name,{method:'POST'});activeJob=j;setButtonsBusy(true);showJob('running',friendlyAction(name),'Wird ausgeführt …');poll(j.id)}catch(e){setButtonsBusy(false);showJob('error','Aktion konnte nicht gestartet werden',e.message,e.message)}}
Binary file not shown.
Binary file not shown.
+23
View File
@@ -3,6 +3,29 @@ set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
BIN_SRC="$ROOT/backend/bin/citizen-launcher"
BIN_DIR="$HOME/.local/bin"
system_package_installed=false
if command -v dpkg-query >/dev/null 2>&1 && dpkg-query -W -f='${Status}' citizen-launcher 2>/dev/null | grep -q 'install ok installed'; then
system_package_installed=true
elif command -v rpm >/dev/null 2>&1 && rpm -q citizen-launcher >/dev/null 2>&1; then
system_package_installed=true
elif command -v pacman >/dev/null 2>&1 && pacman -Q citizen-launcher >/dev/null 2>&1; then
system_package_installed=true
fi
immutable=false
[[ -e /run/ostree-booted || -e /run/transactional-update ]] && immutable=true
if [[ -r /etc/os-release ]]; then
os_id="$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"' | head -n1)"
case "$os_id" in steamos|opensuse-microos|aeon|kalpa) immutable=true;; esac
fi
if $system_package_installed && ! $immutable && [[ "${CITIZEN_LAUNCHER_ALLOW_USER_SHADOW:-0}" != 1 ]]; then
echo "Eine native Citizen-Launcher-Paketinstallation ist bereits vorhanden." >&2
echo "Die ~/.local-Version wird nicht darübergelegt, damit keine alte Version /usr/bin überschattet." >&2
echo "Bitte ./INSTALLIEREN.sh oder das native .deb/.rpm/.pkg.tar.zst für Updates verwenden." >&2
exit 2
fi
APP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps"
+2 -2
View File
@@ -12,7 +12,7 @@ Panel {
property var anchorItem: null
property var hostWidget: null
property string pluginVersion: "1.0.1"
property string pluginVersion: "1.1.1"
property string health: "checking"
property string depsState: "checking"
property string depsMissing: ""
@@ -106,7 +106,7 @@ Panel {
values[lines[i].slice(0, p)] = lines[i].slice(p + 1)
}
pluginVersion = values.plugin_version || "1.0.1"
pluginVersion = values.plugin_version || "1.1.1"
health = values.health || "setup"
depsState = values.deps || "missing"
depsMissing = values.deps_missing || ""
+1 -1
View File
@@ -2,7 +2,7 @@
set -u
PLUGIN_ID="local.omarchy-citizen"
PLUGIN_VERSION="1.0.1"
PLUGIN_VERSION="1.1.1"
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
+1 -1
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"id": "local.omarchy-citizen",
"name": "Citizen Launcher · Omarchy",
"version": "1.0.1",
"version": "1.1.1",
"author": "Community prototype",
"license": "MIT",
"description": "Optional Omarchy bar integration for the distro-neutral Citizen Launcher.",
+23 -30
View File
@@ -1,40 +1,33 @@
# Automatic launcher updates
# Automatic Citizen Launcher updates
## Debian / Ubuntu / Mint
Native mutable packages install `citizen-launcher-self-update.timer`. It checks after boot and roughly every six hours.
The `.deb` installs a root systemd timer:
The release source is read from `/etc/citizen-launcher/release-repo`. Supported formats are:
- `citizen-launcher-self-update.timer`
- checks on boot and every six hours
- reads the latest GitHub Release from the configured repository
- accepts only a newer `citizen-launcher_<version>_amd64.deb`
- requires GitHub's `sha256:` release asset digest
- verifies the downloaded package name, version, and architecture with `dpkg-deb`
- installs via `apt-get` with the dpkg lock timeout enabled
- `github:owner/repository` (legacy `owner/repository` is also accepted)
- `gitea:https://gitea.example/api/v1/repos/owner/repository`
The release repository is stored in:
For privileged system updates, Gitea sources intentionally require HTTPS and the root-owned config file must not be group/world writable. Gitea workflow builds inject their own `${{ gitea.api_url }}` and repository automatically.
```text
/etc/citizen-launcher/release-repo
```
Every privileged update follows the same fail-closed chain:
Default for this project:
1. read and validate the trusted release source;
2. resolve the newest stable release;
3. select only the package format matching the installed package database;
4. require SHA-256 metadata (GitHub API digest or the Gitea release `SHA256SUMS.txt`);
5. download to `/var/cache/citizen-launcher`;
6. verify SHA-256;
7. inspect package name, upstream version and x86-64 architecture;
8. install through the native package database;
9. verify that the installed version actually advanced.
```text
sendnwv/omarchy-sc
```
Native modes:
A package update does not forcibly terminate a running GUI. The existing GUI detects
that `/usr/bin/citizen-launcher` is newer and offers **Launcher neu starten**.
- Debian/Ubuntu/Mint: `.deb` → APT
- Fedora/openSUSE/RHEL family: `.rpm` → RPM database
- Arch family: `.pkg.tar.zst` → pacman
- generic `~/.local`: verified tarball → atomic user-binary replacement
## Generic user install
Immutable OSTree/transactional/SteamOS-style base systems are never modified by the automatic system updater. Their host image owns `/usr`; user-mode Citizen Launcher remains self-updateable.
The Autopilot user timer checks GitHub releases. For a `~/.local` installation it
can replace the launcher binary atomically from the verified Linux tarball without
root privileges.
## Release publishing
`.github/workflows/release.yml` builds and publishes the `.deb`, generic tarball and
SHA256SUMS whenever a `v<VERSION>` tag is pushed. GitHub computes an immutable asset
digest that the Debian self-updater verifies before installation.
An update never forcibly kills the running GUI. The old process detects that the on-disk/package version is newer and offers a controlled restart.
+40
View File
@@ -0,0 +1,40 @@
pkgname=citizen-launcher
pkgver=@VERSION@
pkgrel=1
pkgdesc='Star Citizen setup, launcher and self-maintaining gaming stack for Linux'
arch=('x86_64')
url='https://github.com/sendnwv/omarchy-sc'
license=('MIT')
depends=('ca-certificates' 'tar' 'curl' 'unzip' 'xz' 'zstd' 'polkit' 'util-linux')
optdepends=('pciutils: detailed GPU diagnostics'
'vulkan-tools: detailed Vulkan diagnostics'
'cabextract: use the distro cabextract instead of the portable fallback'
'xdg-utils: additional desktop/browser integration')
options=('!strip')
install='citizen-launcher.install'
source=('citizen-launcher'
'io.github.citizenlauncher.CitizenLauncher.desktop'
'citizen-launcher.svg'
'io.github.citizenlauncher.CitizenLauncher.metainfo.xml'
'citizen-launcher-self-update.service'
'citizen-launcher-self-update.timer'
'citizen-launcher-migrate.desktop'
'90-citizen-launcher.conf'
'90-citizen-launcher-limits.conf'
'release-repo'
'LICENSE')
sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP')
package() {
install -Dm755 "$srcdir/citizen-launcher" "$pkgdir/usr/bin/citizen-launcher"
install -Dm644 "$srcdir/io.github.citizenlauncher.CitizenLauncher.desktop" "$pkgdir/usr/share/applications/io.github.citizenlauncher.CitizenLauncher.desktop"
install -Dm644 "$srcdir/citizen-launcher.svg" "$pkgdir/usr/share/icons/hicolor/scalable/apps/citizen-launcher.svg"
install -Dm644 "$srcdir/io.github.citizenlauncher.CitizenLauncher.metainfo.xml" "$pkgdir/usr/share/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml"
install -Dm644 "$srcdir/citizen-launcher-self-update.service" "$pkgdir/usr/lib/systemd/system/citizen-launcher-self-update.service"
install -Dm644 "$srcdir/citizen-launcher-self-update.timer" "$pkgdir/usr/lib/systemd/system/citizen-launcher-self-update.timer"
install -Dm644 "$srcdir/citizen-launcher-migrate.desktop" "$pkgdir/etc/xdg/autostart/citizen-launcher-migrate.desktop"
install -Dm644 "$srcdir/90-citizen-launcher.conf" "$pkgdir/usr/lib/sysctl.d/90-citizen-launcher.conf"
install -Dm644 "$srcdir/90-citizen-launcher-limits.conf" "$pkgdir/etc/security/limits.d/90-citizen-launcher.conf"
install -Dm644 "$srcdir/release-repo" "$pkgdir/etc/citizen-launcher/release-repo"
install -Dm644 "$srcdir/LICENSE" "$pkgdir/usr/share/licenses/citizen-launcher/LICENSE"
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
"$ROOT/build.sh"
"$ROOT/packaging/build-deb.sh"
"$ROOT/packaging/build-tarball.sh"
if command -v rpmbuild >/dev/null 2>&1; then
"$ROOT/packaging/build-rpm.sh"
else
echo "Hinweis: rpmbuild fehlt – RPM wird in Fedora/openSUSE CI gebaut." >&2
fi
if command -v makepkg >/dev/null 2>&1 && [[ $EUID -ne 0 ]]; then
"$ROOT/packaging/build-arch.sh"
else
echo "Hinweis: makepkg fehlt/Root – Arch-Paket wird in Arch CI gebaut." >&2
fi
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")"
command -v makepkg >/dev/null 2>&1 || { echo "makepkg fehlt. Unter Arch: sudo pacman -S --needed base-devel" >&2; exit 2; }
[[ "$(id -u)" -ne 0 ]] || { echo "makepkg darf nicht als root laufen" >&2; exit 2; }
[[ -x "$ROOT/backend/bin/citizen-launcher" ]] || { echo "Backend fehlt: zuerst ./build.sh" >&2; exit 1; }
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
sed "s/@VERSION@/$VERSION/g" "$ROOT/packaging/arch/PKGBUILD.in" > "$WORK/PKGBUILD"
install -m755 "$ROOT/backend/bin/citizen-launcher" "$WORK/citizen-launcher"
install -m644 "$ROOT/packaging/common/io.github.citizenlauncher.CitizenLauncher.desktop" "$WORK/io.github.citizenlauncher.CitizenLauncher.desktop"
install -m644 "$ROOT/packaging/icons/citizen-launcher.svg" "$WORK/citizen-launcher.svg"
install -m644 "$ROOT/packaging/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml" "$WORK/io.github.citizenlauncher.CitizenLauncher.metainfo.xml"
install -m644 "$ROOT/packaging/systemd/citizen-launcher-self-update.service" "$WORK/citizen-launcher-self-update.service"
install -m644 "$ROOT/packaging/systemd/citizen-launcher-self-update.timer" "$WORK/citizen-launcher-self-update.timer"
install -m644 "$ROOT/packaging/citizen-launcher-migrate.desktop" "$WORK/citizen-launcher-migrate.desktop"
install -m644 "$ROOT/packaging/common/90-citizen-launcher.conf" "$WORK/90-citizen-launcher.conf"
install -m644 "$ROOT/packaging/common/90-citizen-launcher-limits.conf" "$WORK/90-citizen-launcher-limits.conf"
if [[ -n "${CITIZEN_LAUNCHER_RELEASE_REPO:-}" ]]; then
printf '%s\n' "$CITIZEN_LAUNCHER_RELEASE_REPO" > "$WORK/release-repo"
else
install -m644 "$ROOT/packaging/common/release-repo" "$WORK/release-repo"
fi
install -m644 "$ROOT/LICENSE" "$WORK/LICENSE"
install -m644 "$ROOT/packaging/common/citizen-launcher.package-install" "$WORK/citizen-launcher.install"
(
cd "$WORK"
PKGDEST="$ROOT/dist" makepkg --cleanbuild --clean --force --nodeps --noconfirm
)
PKG="$ROOT/dist/citizen-launcher-${VERSION}-1-x86_64.pkg.tar.zst"
[[ -s "$PKG" ]] || { echo "Arch-Paket wurde nicht erzeugt: $PKG" >&2; exit 1; }
echo "$PKG"
+9 -18
View File
@@ -24,9 +24,13 @@ install -m644 "$ROOT/packaging/systemd/citizen-launcher-self-update.timer" "$PKG
install -m644 "$ROOT/packaging/icons/citizen-launcher.svg" "$PKG/usr/share/icons/hicolor/scalable/apps/citizen-launcher.svg"
install -m644 "$ROOT/packaging/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml" "$PKG/usr/share/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml"
install -m644 "$ROOT/packaging/citizen-launcher-migrate.desktop" "$PKG/etc/xdg/autostart/citizen-launcher-migrate.desktop"
printf '%s\n' "${CITIZEN_LAUNCHER_RELEASE_REPO:-sendnwv/omarchy-sc}" > "$PKG/etc/citizen-launcher/release-repo"
printf '%s\n' '# Citizen Launcher / Star Citizen' 'vm.max_map_count = 16777216' > "$PKG/usr/lib/sysctl.d/90-citizen-launcher.conf"
printf '%s\n' '# Citizen Launcher / Star Citizen' '* soft nofile 524288' '* hard nofile 524288' > "$PKG/etc/security/limits.d/90-citizen-launcher.conf"
if [[ -n "${CITIZEN_LAUNCHER_RELEASE_REPO:-}" ]]; then
printf '%s\n' "$CITIZEN_LAUNCHER_RELEASE_REPO" > "$PKG/etc/citizen-launcher/release-repo"
else
install -m644 "$ROOT/packaging/common/release-repo" "$PKG/etc/citizen-launcher/release-repo"
fi
install -m644 "$ROOT/packaging/common/90-citizen-launcher.conf" "$PKG/usr/lib/sysctl.d/90-citizen-launcher.conf"
install -m644 "$ROOT/packaging/common/90-citizen-launcher-limits.conf" "$PKG/etc/security/limits.d/90-citizen-launcher.conf"
install -m755 "$ROOT/packaging/postinst" "$PKG/DEBIAN/postinst"
install -m755 "$ROOT/packaging/prerm" "$PKG/DEBIAN/prerm"
install -m755 "$ROOT/packaging/postrm" "$PKG/DEBIAN/postrm"
@@ -38,27 +42,14 @@ Section: games
Priority: optional
Architecture: $ARCH
Maintainer: Citizen Launcher Project
Depends: ca-certificates, tar, xdg-utils, curl, cabextract, unzip, xz-utils, apt, procps, util-linux, pkexec
Depends: ca-certificates, tar, xdg-utils, curl, cabextract, unzip, xz-utils, zstd, apt, procps, util-linux, pkexec
Recommends: pciutils, vulkan-tools
Description: Star Citizen setup, launcher and self-maintaining gaming stack for Linux
Citizen Launcher manages Wine, DXVK, the RSI Launcher, automatic maintenance,
desktop integration and privacy-conscious support diagnostics.
EOF2
cat > "$PKG/usr/share/applications/io.github.citizenlauncher.CitizenLauncher.desktop" <<'EOF2'
[Desktop Entry]
Name=Citizen Launcher
Comment=Star Citizen for Linux
Exec=/usr/bin/citizen-launcher gui
TryExec=/usr/bin/citizen-launcher
Icon=citizen-launcher
Terminal=false
Type=Application
Categories=Game;
StartupNotify=true
StartupWMClass=CitizenLauncher
Keywords=Star Citizen;RSI;Wine;Gaming;
EOF2
install -m644 "$ROOT/packaging/common/io.github.citizenlauncher.CitizenLauncher.desktop" "$PKG/usr/share/applications/io.github.citizenlauncher.CitizenLauncher.desktop"
mkdir -p "$ROOT/dist"
dpkg-deb --build --root-owner-group "$PKG" "$ROOT/dist/citizen-launcher_${VERSION}_${ARCH}.deb"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")"
command -v rpmbuild >/dev/null 2>&1 || { echo "rpmbuild fehlt. Unter Fedora: sudo dnf install rpm-build" >&2; exit 2; }
[[ -x "$ROOT/backend/bin/citizen-launcher" ]] || { echo "Backend fehlt: zuerst ./build.sh" >&2; exit 1; }
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
TOP="$WORK/rpmbuild"
mkdir -p "$TOP"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
install -m755 "$ROOT/backend/bin/citizen-launcher" "$TOP/SOURCES/citizen-launcher"
install -m644 "$ROOT/packaging/common/io.github.citizenlauncher.CitizenLauncher.desktop" "$TOP/SOURCES/io.github.citizenlauncher.CitizenLauncher.desktop"
install -m644 "$ROOT/packaging/icons/citizen-launcher.svg" "$TOP/SOURCES/citizen-launcher.svg"
install -m644 "$ROOT/packaging/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml" "$TOP/SOURCES/io.github.citizenlauncher.CitizenLauncher.metainfo.xml"
install -m644 "$ROOT/packaging/systemd/citizen-launcher-self-update.service" "$TOP/SOURCES/citizen-launcher-self-update.service"
install -m644 "$ROOT/packaging/systemd/citizen-launcher-self-update.timer" "$TOP/SOURCES/citizen-launcher-self-update.timer"
install -m644 "$ROOT/packaging/citizen-launcher-migrate.desktop" "$TOP/SOURCES/citizen-launcher-migrate.desktop"
install -m644 "$ROOT/packaging/common/90-citizen-launcher.conf" "$TOP/SOURCES/90-citizen-launcher.conf"
install -m644 "$ROOT/packaging/common/90-citizen-launcher-limits.conf" "$TOP/SOURCES/90-citizen-launcher-limits.conf"
if [[ -n "${CITIZEN_LAUNCHER_RELEASE_REPO:-}" ]]; then
printf '%s\n' "$CITIZEN_LAUNCHER_RELEASE_REPO" > "$TOP/SOURCES/release-repo"
else
install -m644 "$ROOT/packaging/common/release-repo" "$TOP/SOURCES/release-repo"
fi
install -m644 "$ROOT/LICENSE" "$TOP/SOURCES/LICENSE"
install -m644 "$ROOT/packaging/rpm/citizen-launcher.spec" "$TOP/SPECS/citizen-launcher.spec"
rpmbuild -bb \
--define "_topdir $TOP" \
--define "cl_version $VERSION" \
--define "_unitdir /usr/lib/systemd/system" \
"$TOP/SPECS/citizen-launcher.spec"
mkdir -p "$ROOT/dist"
RPM="$(find "$TOP/RPMS" -type f -name 'citizen-launcher-*.x86_64.rpm' -print -quit)"
[[ -n "$RPM" ]] || { echo "RPM wurde nicht erzeugt" >&2; exit 1; }
# Normalize the release asset name across Fedora/openSUSE so the secure updater
# has one deterministic RPM target.
cp -f "$RPM" "$ROOT/dist/citizen-launcher-${VERSION}-1.linux.x86_64.rpm"
echo "$ROOT/dist/citizen-launcher-${VERSION}-1.linux.x86_64.rpm"
+101 -10
View File
@@ -1,17 +1,108 @@
Name: citizen-launcher
Version: 1.0.1
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
Name: citizen-launcher
Version: %{cl_version}
Release: 1
Summary: Star Citizen setup, launcher and self-maintaining gaming stack for Linux
License: MIT
URL: https://github.com/sendnwv/omarchy-sc
BuildArch: x86_64
AutoReqProv: no
Requires: ca-certificates
Requires: tar
Requires: curl
Requires: unzip
Requires: xz
Requires: polkit
Requires: util-linux
Recommends: zstd
Recommends: pciutils
Recommends: vulkan-tools
Recommends: cabextract
Recommends: xdg-utils
Source0: citizen-launcher
Source1: io.github.citizenlauncher.CitizenLauncher.desktop
Source2: citizen-launcher.svg
Source3: io.github.citizenlauncher.CitizenLauncher.metainfo.xml
Source4: citizen-launcher-self-update.service
Source5: citizen-launcher-self-update.timer
Source6: citizen-launcher-migrate.desktop
Source7: 90-citizen-launcher.conf
Source8: 90-citizen-launcher-limits.conf
Source9: release-repo
%description
Distro-neutral Citizen Launcher with Wine, DXVK, RSI Launcher management and local GUI.
Citizen Launcher manages a locally tested Wine runner, DXVK, RSI Launcher
compatibility, automatic maintenance, desktop integration and support
diagnostics for Star Citizen on Linux.
%prep
%build
%install
mkdir -p %{buildroot}%{_bindir}
install -m755 citizen-launcher %{buildroot}%{_bindir}/citizen-launcher
install -d %{buildroot}%{_bindir}
install -d %{buildroot}%{_datadir}/applications
install -d %{buildroot}%{_datadir}/icons/hicolor/scalable/apps
install -d %{buildroot}%{_datadir}/metainfo
install -d %{buildroot}%{_unitdir}
install -d %{buildroot}%{_prefix}/lib/sysctl.d
install -d %{buildroot}%{_sysconfdir}/security/limits.d
install -d %{buildroot}%{_sysconfdir}/xdg/autostart
install -d %{buildroot}%{_sysconfdir}/citizen-launcher
install -d %{buildroot}%{_licensedir}/%{name}
install -m755 %{SOURCE0} %{buildroot}%{_bindir}/citizen-launcher
install -m644 %{SOURCE1} %{buildroot}%{_datadir}/applications/io.github.citizenlauncher.CitizenLauncher.desktop
install -m644 %{SOURCE2} %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/citizen-launcher.svg
install -m644 %{SOURCE3} %{buildroot}%{_datadir}/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml
install -m644 %{SOURCE4} %{buildroot}%{_unitdir}/citizen-launcher-self-update.service
install -m644 %{SOURCE5} %{buildroot}%{_unitdir}/citizen-launcher-self-update.timer
install -m644 %{SOURCE6} %{buildroot}%{_sysconfdir}/xdg/autostart/citizen-launcher-migrate.desktop
install -m644 %{SOURCE7} %{buildroot}%{_prefix}/lib/sysctl.d/90-citizen-launcher.conf
install -m644 %{SOURCE8} %{buildroot}%{_sysconfdir}/security/limits.d/90-citizen-launcher.conf
install -m644 %{SOURCE9} %{buildroot}%{_sysconfdir}/citizen-launcher/release-repo
install -m644 %{_sourcedir}/LICENSE %{buildroot}%{_licensedir}/%{name}/LICENSE
%post
if command -v sysctl >/dev/null 2>&1; then
sysctl -q -w vm.max_map_count=16777216 >/dev/null 2>&1 || true
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
immutable=0
[ -e /run/ostree-booted ] && immutable=1
[ -e /run/transactional-update ] && immutable=1
if [ "$immutable" -eq 0 ]; then
systemctl enable --now citizen-launcher-self-update.timer >/dev/null 2>&1 || true
fi
fi
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
fi
%preun
if [ "$1" -eq 0 ] && command -v systemctl >/dev/null 2>&1; then
systemctl disable --now citizen-launcher-self-update.timer >/dev/null 2>&1 || true
fi
%postun
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
%files
%license %{_licensedir}/%{name}/LICENSE
%{_bindir}/citizen-launcher
%{_datadir}/applications/io.github.citizenlauncher.CitizenLauncher.desktop
%{_datadir}/icons/hicolor/scalable/apps/citizen-launcher.svg
%{_datadir}/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml
%{_unitdir}/citizen-launcher-self-update.service
%{_unitdir}/citizen-launcher-self-update.timer
%{_prefix}/lib/sysctl.d/90-citizen-launcher.conf
%{_sysconfdir}/security/limits.d/90-citizen-launcher.conf
%{_sysconfdir}/xdg/autostart/citizen-launcher-migrate.desktop
%config(noreplace) %{_sysconfdir}/citizen-launcher/release-repo
%changelog
* Tue Sep 01 2026 Citizen Launcher Project - 1.1.1-1
- Multi-distribution native package support
@@ -0,0 +1,3 @@
# Citizen Launcher / Star Citizen
* soft nofile 524288
* hard nofile 524288
@@ -0,0 +1,2 @@
# Citizen Launcher / Star Citizen
vm.max_map_count = 16777216
@@ -0,0 +1,27 @@
_is_immutable_host() {
[ -e /run/ostree-booted ] && return 0
[ -e /run/transactional-update ] && return 0
[ -r /etc/os-release ] && grep -Eq '^ID="?(steamos|opensuse-microos|aeon|kalpa)"?$' /etc/os-release && return 0
return 1
}
post_install() {
systemctl daemon-reload >/dev/null 2>&1 || true
if ! _is_immutable_host; then
systemctl enable --now citizen-launcher-self-update.timer >/dev/null 2>&1 || true
fi
sysctl -q -w vm.max_map_count=16777216 >/dev/null 2>&1 || true
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
}
post_upgrade() {
post_install
}
pre_remove() {
systemctl disable --now citizen-launcher-self-update.timer >/dev/null 2>&1 || true
}
post_remove() {
systemctl daemon-reload >/dev/null 2>&1 || true
}
@@ -0,0 +1,12 @@
[Desktop Entry]
Name=Citizen Launcher
Comment=Star Citizen for Linux
Exec=/usr/bin/citizen-launcher gui
TryExec=/usr/bin/citizen-launcher
Icon=citizen-launcher
Terminal=false
Type=Application
Categories=Game;
StartupNotify=true
StartupWMClass=CitizenLauncher
Keywords=Star Citizen;RSI;Wine;Gaming;
+1
View File
@@ -0,0 +1 @@
github:sendnwv/omarchy-sc
@@ -13,6 +13,6 @@
<url type="homepage">https://github.com/sendnwv/omarchy-sc</url>
<categories><category>Game</category></categories>
<provides><binary>citizen-launcher</binary></provides>
<releases><release version="1.0.1" date="2026-08-31"/><release version="1.0.0" date="2026-08-31"/></releases>
<releases><release version="1.1.1" date="2026-09-01"/><release version="1.0.1" date="2026-08-31"/><release version="1.0.0" date="2026-08-31"/></releases>
<content_rating type="oars-1.1"/>
</component>
+108
View File
@@ -0,0 +1,108 @@
Name: citizen-launcher
Version: %{cl_version}
Release: 1
Summary: Star Citizen setup, launcher and self-maintaining gaming stack for Linux
License: MIT
URL: https://github.com/sendnwv/omarchy-sc
BuildArch: x86_64
AutoReqProv: no
Requires: ca-certificates
Requires: tar
Requires: curl
Requires: unzip
Requires: xz
Requires: polkit
Requires: util-linux
Recommends: zstd
Recommends: pciutils
Recommends: vulkan-tools
Recommends: cabextract
Recommends: xdg-utils
Source0: citizen-launcher
Source1: io.github.citizenlauncher.CitizenLauncher.desktop
Source2: citizen-launcher.svg
Source3: io.github.citizenlauncher.CitizenLauncher.metainfo.xml
Source4: citizen-launcher-self-update.service
Source5: citizen-launcher-self-update.timer
Source6: citizen-launcher-migrate.desktop
Source7: 90-citizen-launcher.conf
Source8: 90-citizen-launcher-limits.conf
Source9: release-repo
%description
Citizen Launcher manages a locally tested Wine runner, DXVK, RSI Launcher
compatibility, automatic maintenance, desktop integration and support
diagnostics for Star Citizen on Linux.
%prep
%build
%install
install -d %{buildroot}%{_bindir}
install -d %{buildroot}%{_datadir}/applications
install -d %{buildroot}%{_datadir}/icons/hicolor/scalable/apps
install -d %{buildroot}%{_datadir}/metainfo
install -d %{buildroot}%{_unitdir}
install -d %{buildroot}%{_prefix}/lib/sysctl.d
install -d %{buildroot}%{_sysconfdir}/security/limits.d
install -d %{buildroot}%{_sysconfdir}/xdg/autostart
install -d %{buildroot}%{_sysconfdir}/citizen-launcher
install -d %{buildroot}%{_licensedir}/%{name}
install -m755 %{SOURCE0} %{buildroot}%{_bindir}/citizen-launcher
install -m644 %{SOURCE1} %{buildroot}%{_datadir}/applications/io.github.citizenlauncher.CitizenLauncher.desktop
install -m644 %{SOURCE2} %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/citizen-launcher.svg
install -m644 %{SOURCE3} %{buildroot}%{_datadir}/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml
install -m644 %{SOURCE4} %{buildroot}%{_unitdir}/citizen-launcher-self-update.service
install -m644 %{SOURCE5} %{buildroot}%{_unitdir}/citizen-launcher-self-update.timer
install -m644 %{SOURCE6} %{buildroot}%{_sysconfdir}/xdg/autostart/citizen-launcher-migrate.desktop
install -m644 %{SOURCE7} %{buildroot}%{_prefix}/lib/sysctl.d/90-citizen-launcher.conf
install -m644 %{SOURCE8} %{buildroot}%{_sysconfdir}/security/limits.d/90-citizen-launcher.conf
install -m644 %{SOURCE9} %{buildroot}%{_sysconfdir}/citizen-launcher/release-repo
install -m644 %{_sourcedir}/LICENSE %{buildroot}%{_licensedir}/%{name}/LICENSE
%post
if command -v sysctl >/dev/null 2>&1; then
sysctl -q -w vm.max_map_count=16777216 >/dev/null 2>&1 || true
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
immutable=0
[ -e /run/ostree-booted ] && immutable=1
[ -e /run/transactional-update ] && immutable=1
if [ "$immutable" -eq 0 ]; then
systemctl enable --now citizen-launcher-self-update.timer >/dev/null 2>&1 || true
fi
fi
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
fi
%preun
if [ "$1" -eq 0 ] && command -v systemctl >/dev/null 2>&1; then
systemctl disable --now citizen-launcher-self-update.timer >/dev/null 2>&1 || true
fi
%postun
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
%files
%license %{_licensedir}/%{name}/LICENSE
%{_bindir}/citizen-launcher
%{_datadir}/applications/io.github.citizenlauncher.CitizenLauncher.desktop
%{_datadir}/icons/hicolor/scalable/apps/citizen-launcher.svg
%{_datadir}/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml
%{_unitdir}/citizen-launcher-self-update.service
%{_unitdir}/citizen-launcher-self-update.timer
%{_prefix}/lib/sysctl.d/90-citizen-launcher.conf
%{_sysconfdir}/security/limits.d/90-citizen-launcher.conf
%{_sysconfdir}/xdg/autostart/citizen-launcher-migrate.desktop
%config(noreplace) %{_sysconfdir}/citizen-launcher/release-repo
%changelog
* Tue Sep 01 2026 Citizen Launcher Project - 1.1.1-1
- Multi-distribution native package support
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -euo pipefail
: "${GITEA_API_URL:?GITEA_API_URL is required}"
: "${GITEA_REPOSITORY:?GITEA_REPOSITORY is required (owner/repo)}"
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
api_base="${GITEA_API_URL%/}/repos/${GITEA_REPOSITORY}"
auth=( -H "Authorization: token ${GITEA_TOKEN}" )
urlencode() {
python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1"
}
release_json() {
local tag="$1" enc
enc="$(urlencode "$tag")"
curl -fsS "${auth[@]}" "${api_base}/releases/tags/${enc}"
}
release_payload() {
local tag="$1" title="$2" body_file="$3" target="${4:-}"
python3 - "$tag" "$title" "$body_file" "$target" <<'PY'
import json, pathlib, sys
body_path = pathlib.Path(sys.argv[3])
body = body_path.read_text(encoding='utf-8') if body_path.exists() else ''
print(json.dumps({
'tag_name': sys.argv[1],
'name': sys.argv[2],
'body': body,
'draft': False,
'prerelease': False,
'target_commitish': sys.argv[4],
}))
PY
}
ensure_release() {
local tag="$1" title="$2" body_file="$3" target="${4:-}"
local payload current id
payload="$(release_payload "$tag" "$title" "$body_file" "$target")"
if current="$(release_json "$tag" 2>/dev/null)"; then
id="$(printf '%s' "$current" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
curl -fsS "${auth[@]}" -H 'Content-Type: application/json' \
-X PATCH -d "$payload" "${api_base}/releases/${id}" >/dev/null
echo "Updated Gitea release ${tag}"
return 0
fi
curl -fsS "${auth[@]}" -H 'Content-Type: application/json' \
-X POST -d "$payload" "${api_base}/releases" >/dev/null
echo "Created Gitea release ${tag}"
}
upload_asset() {
local tag="$1" file="$2"
[[ -s "$file" ]] || { echo "Asset missing/empty: $file" >&2; exit 1; }
local rel id name enc existing_ids
rel="$(release_json "$tag")"
id="$(printf '%s' "$rel" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
name="$(basename "$file")"
enc="$(urlencode "$name")"
existing_ids="$(printf '%s' "$rel" | python3 -c 'import json,sys; name=sys.argv[1]; [print(a["id"]) for a in json.load(sys.stdin).get("assets",[]) if a.get("name")==name]' "$name")"
if [[ -n "$existing_ids" ]]; then
while IFS= read -r aid; do
[[ -n "$aid" ]] || continue
curl -fsS "${auth[@]}" -X DELETE "${api_base}/releases/${id}/assets/${aid}" >/dev/null
done <<< "$existing_ids"
fi
curl -fsS "${auth[@]}" -X POST \
-F "attachment=@${file}" \
"${api_base}/releases/${id}/assets?name=${enc}" >/dev/null
echo "Uploaded ${name}"
}
download_assets() {
local tag="$1" dir="$2"
mkdir -p "$dir"
local rel
rel="$(release_json "$tag")"
printf '%s' "$rel" | python3 -c '
import json, os, pathlib, sys, urllib.request
out = pathlib.Path(sys.argv[1])
release = json.load(sys.stdin)
for asset in release.get("assets", []):
name = asset.get("name", "")
if not name or name == "SHA256SUMS.txt":
continue
if not (name.endswith(".deb") or name.endswith(".rpm") or name.endswith(".pkg.tar.zst") or name.endswith("-linux-amd64.tar.gz")):
continue
req = urllib.request.Request(asset["browser_download_url"])
token = os.environ.get("GITEA_TOKEN", "")
if token:
req.add_header("Authorization", f"token {token}")
with urllib.request.urlopen(req, timeout=120) as r, open(out / name, "wb") as f:
while True:
block = r.read(1024 * 1024)
if not block:
break
f.write(block)
print(name)
' "$dir"
}
case "${1:-}" in
ensure)
[[ $# -ge 4 ]] || { echo "usage: $0 ensure TAG TITLE BODY_FILE [TARGET]" >&2; exit 2; }
ensure_release "$2" "$3" "$4" "${5:-}"
;;
upload)
[[ $# -eq 3 ]] || { echo "usage: $0 upload TAG FILE" >&2; exit 2; }
upload_asset "$2" "$3"
;;
download-assets)
[[ $# -eq 3 ]] || { echo "usage: $0 download-assets TAG DIR" >&2; exit 2; }
download_assets "$2" "$3"
;;
*)
echo "usage: $0 {ensure|upload|download-assets} ..." >&2
exit 2
;;
esac
+1
View File
@@ -4,5 +4,6 @@ ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
"$ROOT/tests/verify.sh"
(cd "$ROOT/backend" && go test -race ./...)
"$ROOT/backend/integration-test.sh"
"$ROOT/tests/gitea-release-helper.sh"
"$ROOT/tests/package-verify.sh"
echo 'Citizen Launcher full verification: OK'
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d)"
PID=""
cleanup() {
[[ -z "$PID" ]] || kill "$PID" >/dev/null 2>&1 || true
rm -rf "$TMP"
}
trap cleanup EXIT
cat > "$TMP/server.py" <<'PY'
import http.server, json, re, urllib.parse
assets=[]
release=None
next_asset=1
class H(http.server.BaseHTTPRequestHandler):
def log_message(self,*a): pass
def sendj(self, code, obj):
b=json.dumps(obj).encode(); self.send_response(code); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
def rel(self):
if release is None: return None
r=dict(release)
r['assets']=[{'id':a['id'],'name':a['name'],'size':len(a['data']),'browser_download_url':f'http://127.0.0.1:{self.server.server_address[1]}/download/{urllib.parse.quote(a["name"])}'} for a in assets]
return r
def do_GET(self):
path=urllib.parse.urlparse(self.path).path
if path.endswith('/releases/tags/v1.1.1'):
if release is None: self.send_error(404); return
self.sendj(200,self.rel()); return
if path.startswith('/download/'):
name=urllib.parse.unquote(path.split('/download/',1)[1])
for a in assets:
if a['name']==name:
b=a['data']; self.send_response(200); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b); return
self.send_error(404); return
self.send_error(404)
def do_POST(self):
global release,next_asset
parsed=urllib.parse.urlparse(self.path); path=parsed.path
if path.endswith('/releases'):
n=int(self.headers.get('Content-Length','0')); payload=json.loads(self.rfile.read(n) or b'{}')
release={'id':1,'tag_name':payload['tag_name'],'name':payload.get('name','')}; self.sendj(201,self.rel()); return
if re.search(r'/releases/\d+/assets$',path):
n=int(self.headers.get('Content-Length','0')); body=self.rfile.read(n)
name=urllib.parse.parse_qs(parsed.query).get('name',['asset'])[0]
pos=body.find(b'\r\n\r\n'); data=body[pos+4:] if pos>=0 else body
ct=self.headers.get('Content-Type',''); boundary=ct.split('boundary=',1)[1].encode() if 'boundary=' in ct else b''
marker=b'\r\n--'+boundary
if boundary and marker in data: data=data.split(marker,1)[0]
a={'id':next_asset,'name':name,'data':data}; next_asset+=1; assets.append(a)
self.sendj(201,{'id':a['id'],'name':name}); return
self.send_error(404)
def do_PATCH(self):
global release
path=urllib.parse.urlparse(self.path).path
if re.search(r'/releases/\d+$', path):
n=int(self.headers.get('Content-Length','0')); payload=json.loads(self.rfile.read(n) or b'{}')
release={'id':1,'tag_name':payload['tag_name'],'name':payload.get('name','')}
self.sendj(200,self.rel()); return
self.send_error(404)
def do_DELETE(self):
m=re.search(r'/releases/\d+/assets/(\d+)$',urllib.parse.urlparse(self.path).path)
if m:
aid=int(m.group(1)); assets[:]=[a for a in assets if a['id']!=aid]
self.send_response(204); self.end_headers(); return
self.send_error(404)
httpd=http.server.ThreadingHTTPServer(('127.0.0.1',0),H)
print(httpd.server_address[1], flush=True)
httpd.serve_forever()
PY
python3 "$TMP/server.py" > "$TMP/port" &
PID=$!
for _ in $(seq 1 50); do [[ -s "$TMP/port" ]] && break; sleep .05; done
PORT="$(cat "$TMP/port")"
export GITEA_API_URL="http://127.0.0.1:${PORT}/api/v1"
export GITEA_REPOSITORY="owner/repo"
export GITEA_TOKEN="test-token"
echo first > "$TMP/citizen-launcher_1.1.1_amd64.deb"
"$ROOT/scripts/gitea-release.sh" ensure v1.1.1 'Citizen Launcher 1.1.1' "$ROOT/RELEASE_NOTES.md" deadbeef
"$ROOT/scripts/gitea-release.sh" ensure v1.1.1 'Citizen Launcher 1.1.1' "$ROOT/RELEASE_NOTES.md" deadbeef
"$ROOT/scripts/gitea-release.sh" upload v1.1.1 "$TMP/citizen-launcher_1.1.1_amd64.deb"
echo replacement > "$TMP/citizen-launcher_1.1.1_amd64.deb"
"$ROOT/scripts/gitea-release.sh" upload v1.1.1 "$TMP/citizen-launcher_1.1.1_amd64.deb"
"$ROOT/scripts/gitea-release.sh" download-assets v1.1.1 "$TMP/download"
cmp "$TMP/citizen-launcher_1.1.1_amd64.deb" "$TMP/download/citizen-launcher_1.1.1_amd64.deb"
echo 'Gitea release helper verification: OK'
+25
View File
@@ -22,6 +22,7 @@ for f in \
usr/lib/sysctl.d/90-citizen-launcher.conf \
etc/security/limits.d/90-citizen-launcher.conf \
etc/xdg/autostart/citizen-launcher-migrate.desktop \
etc/citizen-launcher/release-repo \
usr/share/applications/io.github.citizenlauncher.CitizenLauncher.desktop \
usr/share/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml; do
[[ -f "$TMP/root/$f" ]] || { echo "Missing DEB payload: $f" >&2; exit 1; }
@@ -31,4 +32,28 @@ tar -xzf "$TAR" -C "$TMP/tar"
[[ "$("$TMP/tar/citizen-launcher" --version)" == "$VERSION" ]]
grep -q '^Exec=/usr/bin/citizen-launcher gui$' "$TMP/root/usr/share/applications/io.github.citizenlauncher.CitizenLauncher.desktop"
grep -q '^ExecStart=/usr/bin/citizen-launcher self-update apply --system --quiet$' "$TMP/root/usr/lib/systemd/system/citizen-launcher-self-update.service"
grep -q 'zstd' <(dpkg-deb -f "$DEB" Depends)
# Packaging definitions for the other native families are validated on every
# host; their actual package builders are executed when their native tools exist
# and in GitHub's Fedora/Arch CI jobs.
bash -n "$ROOT/INSTALLIEREN.sh" "$ROOT/packaging/build-rpm.sh" "$ROOT/packaging/build-arch.sh" "$ROOT/packaging/build-all.sh"
grep -q '^Name:[[:space:]]*citizen-launcher$' "$ROOT/packaging/rpm/citizen-launcher.spec"
grep -q 'citizen-launcher-self-update.timer' "$ROOT/packaging/rpm/citizen-launcher.spec"
grep -q '^pkgname=citizen-launcher$' "$ROOT/packaging/arch/PKGBUILD.in"
grep -q "'x86_64'" "$ROOT/packaging/arch/PKGBUILD.in"
grep -q 'citizen-launcher-self-update.timer' "$ROOT/packaging/arch/PKGBUILD.in"
if command -v rpmbuild >/dev/null 2>&1; then
"$ROOT/packaging/build-rpm.sh"
RPM="$ROOT/dist/citizen-launcher-${VERSION}-1.linux.x86_64.rpm"
[[ -s "$RPM" ]]
rpm -qp --qf '%{NAME}\n%{VERSION}\n%{ARCH}\n' "$RPM" | grep -qx 'citizen-launcher' -m1
fi
if command -v makepkg >/dev/null 2>&1 && [[ $EUID -ne 0 ]]; then
"$ROOT/packaging/build-arch.sh"
[[ -s "$ROOT/dist/citizen-launcher-${VERSION}-1-x86_64.pkg.tar.zst" ]]
fi
echo 'Citizen Launcher package verification: OK'
+22 -12
View File
@@ -1,17 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
for f in build.sh install.sh install-omarchy.sh uninstall.sh packaging/build-deb.sh packaging/build-tarball.sh backend/integration-test.sh integrations/omarchy/citizenctl; do
bash -n "$ROOT/$f"
for f in \
"$ROOT/install.sh" "$ROOT/uninstall.sh" "$ROOT/install-omarchy.sh" "$ROOT/INSTALLIEREN.sh" \
"$ROOT/build.sh" "$ROOT/packaging/build-deb.sh" "$ROOT/packaging/build-rpm.sh" \
"$ROOT/packaging/build-arch.sh" "$ROOT/packaging/build-tarball.sh" "$ROOT/packaging/build-all.sh" \
"$ROOT/scripts/gitea-release.sh" "$ROOT/tests/gitea-release-helper.sh"; do
bash -n "$f"
done
UNFORMATTED="$(cd "$ROOT/backend" && gofmt -l cmd/citizen-launcher/*.go)"
if [[ -n "$UNFORMATTED" ]]; then
echo "Go source is not gofmt-clean:" >&2
echo "$UNFORMATTED" >&2
exit 1
fi
(cd "$ROOT/backend" && go test ./... && go vet ./... && ./build.sh)
"$ROOT/backend/bin/citizen-launcher" --version | grep -qx "$(tr -d '[:space:]' < "$ROOT/VERSION")"
"$ROOT/backend/bin/citizen-launcher" platform --json | grep -q '"id"'
grep -a -q 'FLIGHT READY' "$ROOT/backend/bin/citizen-launcher"
(cd "$ROOT/backend" && gofmt -w cmd/citizen-launcher && go test ./... && go vet ./...)
"$ROOT/build.sh"
[[ "$("$ROOT/backend/bin/citizen-launcher" --version)" == "$(tr -d '[:space:]' < "$ROOT/VERSION")" ]]
[[ ! -n "$(gofmt -l "$ROOT/backend/cmd/citizen-launcher")" ]]
python3 - <<'PY' "$ROOT"
import pathlib, sys, yaml
root=pathlib.Path(sys.argv[1])
for p in [root/'.gitea/workflows/ci.yml', root/'.gitea/workflows/release.yml']:
yaml.safe_load(p.read_text())
assert not (root/'.github/workflows').exists(), 'legacy GitHub workflows must not shadow Gitea workflows'
release=(root/'.gitea/workflows/release.yml').read_text()
assert 'gh release' not in release
assert 'gitea.api_url' in release and 'gitea.token' in release
assert 'actions/upload-artifact' not in release and 'actions/download-artifact' not in release
print('Gitea workflow yaml: OK')
PY
echo 'Citizen Launcher verification: OK'