diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..6ea1c7b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..2448404 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bee7136..6ab8b97 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/DISTRO_SUPPORT.md b/DISTRO_SUPPORT.md index b82d403..2560299 100644 --- a/DISTRO_SUPPORT.md +++ b/DISTRO_SUPPORT.md @@ -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. diff --git a/GITEA.md b/GITEA.md new file mode 100644 index 0000000..d826175 --- /dev/null +++ b/GITEA.md @@ -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:/repos// +``` + +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. diff --git a/INSTALLIEREN.sh b/INSTALLIEREN.sh index 3c44a2a..60902cc 100644 --- a/INSTALLIEREN.sh +++ b/INSTALLIEREN.sh @@ -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" diff --git a/README.md b/README.md index f1f9028..fdf4898 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7ca8b7a..5746ac0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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:` 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. diff --git a/TEST_REPORT.md b/TEST_REPORT.md index daba497..26855c2 100644 --- a/TEST_REPORT.md +++ b/TEST_REPORT.md @@ -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:/repos// +``` -- `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. diff --git a/VERSION b/VERSION index 7dea76e..524cb55 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.1 +1.1.1 diff --git a/backend/bin/citizen-launcher b/backend/bin/citizen-launcher index c719ffe..7bf37bf 100644 Binary files a/backend/bin/citizen-launcher and b/backend/bin/citizen-launcher differ diff --git a/backend/build.sh b/backend/build.sh index 3eb6afb..f0e38fb 100644 --- a/backend/build.sh +++ b/backend/build.sh @@ -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" \ diff --git a/backend/cmd/citizen-launcher/gui.go b/backend/cmd/citizen-launcher/gui.go index 5d2b7ef..da9043c 100644 --- a/backend/cmd/citizen-launcher/gui.go +++ b/backend/cmd/citizen-launcher/gui.go @@ -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/")) diff --git a/backend/cmd/citizen-launcher/integration.go b/backend/cmd/citizen-launcher/integration.go index 028fd0b..7d518bd 100644 --- a/backend/cmd/citizen-launcher/integration.go +++ b/backend/cmd/citizen-launcher/integration.go @@ -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 { diff --git a/backend/cmd/citizen-launcher/main.go b/backend/cmd/citizen-launcher/main.go index d69e8b7..e844bd5 100644 --- a/backend/cmd/citizen-launcher/main.go +++ b/backend/cmd/citizen-launcher/main.go @@ -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) } diff --git a/backend/cmd/citizen-launcher/platform.go b/backend/cmd/citizen-launcher/platform.go index d10cc1e..9269411 100644 --- a/backend/cmd/citizen-launcher/platform.go +++ b/backend/cmd/citizen-launcher/platform.go @@ -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 +} diff --git a/backend/cmd/citizen-launcher/platform_test.go b/backend/cmd/citizen-launcher/platform_test.go new file mode 100644 index 0000000..8c6ecce --- /dev/null +++ b/backend/cmd/citizen-launcher/platform_test.go @@ -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") + } +} diff --git a/backend/cmd/citizen-launcher/selfupdate.go b/backend/cmd/citizen-launcher/selfupdate.go index 9e423d3..f804714 100644 --- a/backend/cmd/citizen-launcher/selfupdate.go +++ b/backend/cmd/citizen-launcher/selfupdate.go @@ -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 diff --git a/backend/cmd/citizen-launcher/selfupdate_test.go b/backend/cmd/citizen-launcher/selfupdate_test.go index 0d37ab3..974d84d 100644 --- a/backend/cmd/citizen-launcher/selfupdate_test.go +++ b/backend/cmd/citizen-launcher/selfupdate_test.go @@ -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") + } +} diff --git a/backend/cmd/citizen-launcher/stack.go b/backend/cmd/citizen-launcher/stack.go index fd7e4ae..069c675 100644 --- a/backend/cmd/citizen-launcher/stack.go +++ b/backend/cmd/citizen-launcher/stack.go @@ -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 { diff --git a/backend/cmd/citizen-launcher/web/app.js b/backend/cmd/citizen-launcher/web/app.js index 17e7ab7..ef7f806 100644 --- a/backend/cmd/citizen-launcher/web/app.js +++ b/backend/cmd/citizen-launcher/web/app.js @@ -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)}} diff --git a/dist/citizen-launcher-1.1.1-linux-amd64.tar.gz b/dist/citizen-launcher-1.1.1-linux-amd64.tar.gz new file mode 100644 index 0000000..d8ba66a Binary files /dev/null and b/dist/citizen-launcher-1.1.1-linux-amd64.tar.gz differ diff --git a/dist/citizen-launcher_1.1.1_amd64.deb b/dist/citizen-launcher_1.1.1_amd64.deb new file mode 100644 index 0000000..ba54ee6 Binary files /dev/null and b/dist/citizen-launcher_1.1.1_amd64.deb differ diff --git a/install.sh b/install.sh index f0ecfdc..4b154a9 100644 --- a/install.sh +++ b/install.sh @@ -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" diff --git a/integrations/omarchy/Panel.qml b/integrations/omarchy/Panel.qml index c25f87e..38b4fc2 100644 --- a/integrations/omarchy/Panel.qml +++ b/integrations/omarchy/Panel.qml @@ -12,7 +12,7 @@ Panel { property var anchorItem: null property var hostWidget: null - property string pluginVersion: "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 || "" diff --git a/integrations/omarchy/citizenctl b/integrations/omarchy/citizenctl index ce9a5f6..3dc8529 100644 --- a/integrations/omarchy/citizenctl +++ b/integrations/omarchy/citizenctl @@ -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}" diff --git a/integrations/omarchy/manifest.json b/integrations/omarchy/manifest.json index ded468f..17e8353 100644 --- a/integrations/omarchy/manifest.json +++ b/integrations/omarchy/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "local.omarchy-citizen", "name": "Citizen Launcher · Omarchy", - "version": "1.0.1", + "version": "1.1.1", "author": "Community prototype", "license": "MIT", "description": "Optional Omarchy bar integration for the distro-neutral Citizen Launcher.", diff --git a/packaging/SELF_UPDATE.md b/packaging/SELF_UPDATE.md index 6821000..d33364d 100644 --- a/packaging/SELF_UPDATE.md +++ b/packaging/SELF_UPDATE.md @@ -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__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` 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. diff --git a/packaging/arch/PKGBUILD.in b/packaging/arch/PKGBUILD.in new file mode 100644 index 0000000..ebb84be --- /dev/null +++ b/packaging/arch/PKGBUILD.in @@ -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" +} diff --git a/packaging/build-all.sh b/packaging/build-all.sh new file mode 100644 index 0000000..b7585e4 --- /dev/null +++ b/packaging/build-all.sh @@ -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 diff --git a/packaging/build-arch.sh b/packaging/build-arch.sh new file mode 100644 index 0000000..d3ece29 --- /dev/null +++ b/packaging/build-arch.sh @@ -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" diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 4f44a40..beb9a33 100644 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -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" diff --git a/packaging/build-rpm.sh b/packaging/build-rpm.sh new file mode 100644 index 0000000..d887bf4 --- /dev/null +++ b/packaging/build-rpm.sh @@ -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" diff --git a/packaging/citizen-launcher.spec b/packaging/citizen-launcher.spec index c5461e9..2551d74 100644 --- a/packaging/citizen-launcher.spec +++ b/packaging/citizen-launcher.spec @@ -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 diff --git a/packaging/common/90-citizen-launcher-limits.conf b/packaging/common/90-citizen-launcher-limits.conf new file mode 100644 index 0000000..68a3234 --- /dev/null +++ b/packaging/common/90-citizen-launcher-limits.conf @@ -0,0 +1,3 @@ +# Citizen Launcher / Star Citizen +* soft nofile 524288 +* hard nofile 524288 diff --git a/packaging/common/90-citizen-launcher.conf b/packaging/common/90-citizen-launcher.conf new file mode 100644 index 0000000..410e7d7 --- /dev/null +++ b/packaging/common/90-citizen-launcher.conf @@ -0,0 +1,2 @@ +# Citizen Launcher / Star Citizen +vm.max_map_count = 16777216 diff --git a/packaging/common/citizen-launcher.package-install b/packaging/common/citizen-launcher.package-install new file mode 100644 index 0000000..98aaa6a --- /dev/null +++ b/packaging/common/citizen-launcher.package-install @@ -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 +} diff --git a/packaging/common/io.github.citizenlauncher.CitizenLauncher.desktop b/packaging/common/io.github.citizenlauncher.CitizenLauncher.desktop new file mode 100644 index 0000000..b4427ed --- /dev/null +++ b/packaging/common/io.github.citizenlauncher.CitizenLauncher.desktop @@ -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; diff --git a/packaging/common/release-repo b/packaging/common/release-repo new file mode 100644 index 0000000..e211050 --- /dev/null +++ b/packaging/common/release-repo @@ -0,0 +1 @@ +github:sendnwv/omarchy-sc diff --git a/packaging/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml b/packaging/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml index 387a183..c394248 100644 --- a/packaging/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml +++ b/packaging/metainfo/io.github.citizenlauncher.CitizenLauncher.metainfo.xml @@ -13,6 +13,6 @@ https://github.com/sendnwv/omarchy-sc Game citizen-launcher - + diff --git a/packaging/rpm/citizen-launcher.spec b/packaging/rpm/citizen-launcher.spec new file mode 100644 index 0000000..2551d74 --- /dev/null +++ b/packaging/rpm/citizen-launcher.spec @@ -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 diff --git a/scripts/gitea-release.sh b/scripts/gitea-release.sh new file mode 100644 index 0000000..df3eac4 --- /dev/null +++ b/scripts/gitea-release.sh @@ -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 diff --git a/tests/full-verify.sh b/tests/full-verify.sh index 361446e..6c27f8a 100644 --- a/tests/full-verify.sh +++ b/tests/full-verify.sh @@ -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' diff --git a/tests/gitea-release-helper.sh b/tests/gitea-release-helper.sh new file mode 100644 index 0000000..4cd6438 --- /dev/null +++ b/tests/gitea-release-helper.sh @@ -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' diff --git a/tests/package-verify.sh b/tests/package-verify.sh index 07a51a2..2e63991 100644 --- a/tests/package-verify.sh +++ b/tests/package-verify.sh @@ -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' diff --git a/tests/verify.sh b/tests/verify.sh index a491de2..5a6129b 100644 --- a/tests/verify.sh +++ b/tests/verify.sh @@ -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'