This commit is contained in:
2026-08-31 20:38:41 +02:00
parent 129fbe485b
commit eefada36e9
26 changed files with 710 additions and 26 deletions
+19 -2
View File
@@ -1,4 +1,4 @@
# Citizen Launcher 0.9.1
# Citizen Launcher 0.9.2
Citizen Launcher is the distro-neutral successor to Omarchy Citizen.
@@ -81,10 +81,27 @@ For Omarchy plus bar integration:
Citizen Launcher manages its Wine/DXVK/RSI stack entirely as the current user. It does not create passwordless sudo/pacman/apt rules. GPU/kernel/base-system updates remain owned by the distribution.
## 0.9.1 fixes
## 0.9.2 fixes
- PowerShell is no longer a mandatory prefix component.
- RSI `latest.yml` parser accepts Electron Builder `path:` and nested `files: - url:` formats.
- Repair also refreshes DXVK.
- Responsive dashboard prevents long GPU/distribution names from overflowing.
- GUI shows concise user-facing errors with expandable technical details.
## Automatic Citizen Launcher updates
Starting with 0.9.2 the launcher itself is part of Autopilot.
### Debian / Ubuntu / Mint
Installing the `.deb` once enables `citizen-launcher-self-update.timer`. Future
Citizen Launcher releases are checked automatically every six hours. A new `.deb`
is only installed after its GitHub SHA-256 asset digest and Debian package metadata
have been verified.
The currently running GUI is never killed during package replacement. If an update
landed while the GUI was open, the app shows **Neue Version installiert** and offers
a controlled restart into the new binary.
See `packaging/SELF_UPDATE.md` for details.
+17 -1
View File
@@ -1,4 +1,4 @@
# Citizen Launcher 0.9.1
# Citizen Launcher 0.9.2
Maintenance/UX hotfix for the distro-neutral preview.
@@ -7,3 +7,19 @@ Maintenance/UX hotfix for the distro-neutral preview.
- DXVK is included in automatic repair.
- Responsive GUI with safer grid sizing and readable diagnostics.
- Added regression tests for RSI metadata formats and the minimal prefix contract.
## 0.9.2
- fixes `Wine Registry: reg: Angegebener Schlüssel nicht zugreifbar oder erstellbar`
- registry path now uses valid single separators
- file-association tweak is non-fatal because it is not required by Star Citizen
- adds automatic application updates
- Debian package installs a root systemd updater timer
- updater checks GitHub Releases every six hours
- downloads only the expected `citizen-launcher_<version>_amd64.deb`
- requires and verifies GitHub's SHA-256 asset digest
- verifies package name/version/architecture before APT installation
- generic user installs self-update from the verified tarball
- GUI shows whether automatic package updates are active
- a running old GUI detects when a newer package was installed and offers a one-click restart
- adds a GitHub Actions release workflow and central `VERSION` file
+1
View File
@@ -0,0 +1 @@
0.9.2
Binary file not shown.
+4 -1
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env bash
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}"
mkdir -p bin
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-trimpath -ldflags='-s -w' \
-trimpath -ldflags="-s -w -X main.appVersion=$VERSION -X main.releaseRepo=$RELEASE_REPO" \
-o bin/citizen-launcher \
./cmd/citizen-launcher
sha256sum bin/citizen-launcher
+32 -5
View File
@@ -10,6 +10,7 @@ import (
"io/fs"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -21,10 +22,13 @@ import (
var guiFiles embed.FS
type GUIStatus struct {
Version string `json:"version"`
Platform PlatformStatus `json:"platform"`
Game GameStatus `json:"game"`
Launcher Status `json:"launcher"`
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"`
}
type GUIJob struct {
@@ -100,7 +104,11 @@ func (a *App) runGUI(args []string) error {
}
mux.HandleFunc(base+"/api/status", func(w http.ResponseWriter, r *http.Request) {
st, _ := a.status(false)
jsonOut(w, GUIStatus{Version: appVersion, Platform: detectPlatform(), Game: a.gameStatus(), Launcher: st})
su, _ := a.selfUpdateStatus(false)
jsonOut(w, GUIStatus{
Version: appVersion, InstalledVersion: su.Installed, RestartRequired: su.RestartNeeded,
PackageAutoUpdate: packageAutoUpdateActive(), Platform: detectPlatform(), Game: a.gameStatus(), Launcher: st,
})
})
mux.HandleFunc(base+"/api/job/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, base+"/api/job/")
@@ -119,6 +127,25 @@ func (a *App) runGUI(args []string) error {
return
}
action := strings.TrimPrefix(r.URL.Path, base+"/api/action/")
if action == "restart" {
target := a.selfPath
if installedPackageVersion() != "" {
if _, err := os.Stat("/usr/bin/citizen-launcher"); err == nil {
target = "/usr/bin/citizen-launcher"
}
}
if target == "" {
http.Error(w, "launcher executable not found", 500)
return
}
if err := exec.Command(target, "gui").Start(); err != nil {
http.Error(w, err.Error(), 500)
return
}
jsonOut(w, map[string]string{"state": "restarting"})
go func() { time.Sleep(450 * time.Millisecond); os.Exit(0) }()
return
}
if action == "launch" {
if err := a.gameLaunch(); err != nil {
http.Error(w, err.Error(), 500)
+45 -3
View File
@@ -15,10 +15,14 @@ import (
"time"
)
var (
appVersion = "0.9.2"
releaseRepo = "sendnwv/omarchy-sc"
)
const (
appVersion = "0.9.1"
pluginID = "local.omarchy-citizen"
appID = "io.github.citizenlauncher.CitizenLauncher"
pluginID = "local.omarchy-citizen"
appID = "io.github.citizenlauncher.CitizenLauncher"
)
type Config struct {
@@ -108,6 +112,35 @@ func main() {
app.logf("manual update failed: %v", err)
fatal(err)
}
case "self-update":
if len(args) < 2 {
args = append(args, "check")
}
switch args[1] {
case "check":
st, err := app.selfUpdateStatus(true)
if hasArg(args[2:], "--json") {
printJSON(st)
} else {
printSelfUpdateKV(st)
}
if err != nil {
fatal(err)
}
case "status":
st, _ := app.selfUpdateStatus(false)
if hasArg(args[2:], "--json") {
printJSON(st)
} else {
printSelfUpdateKV(st)
}
case "apply":
if err := app.applySelfUpdate(hasArg(args[2:], "--system"), hasArg(args[2:], "--quiet")); err != nil {
fatal(err)
}
default:
fatal(errors.New("usage: self-update check|status|apply [--system] [--quiet]"))
}
case "tick":
if err := app.tick(); err != nil {
app.logf("tick failed: %v", err)
@@ -364,6 +397,15 @@ func (a *App) tick() error {
if err := a.maintainGamingStack(); err != nil {
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.
if su, err := a.selfUpdateStatus(true); err != nil {
a.logf("launcher release check warning: %v", err)
} else if su.State == "available" && su.Mode == "user" {
if err := a.applySelfUpdate(false, true); err != nil {
problems = append(problems, "launcher update: "+err.Error())
}
}
}
if cfg.AutoApply {
+401
View File
@@ -0,0 +1,401 @@
package main
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
type SelfUpdateStatus struct {
Current string `json:"current"`
Installed string `json:"installed,omitempty"`
Latest string `json:"latest,omitempty"`
State string `json:"state"`
Mode string `json:"mode"`
Repository string `json:"repository"`
Asset string `json:"asset,omitempty"`
Digest string `json:"digest,omitempty"`
RestartNeeded bool `json:"restart_needed"`
CheckedAt string `json:"checked_at,omitempty"`
Error string `json:"error,omitempty"`
}
func packageAutoUpdateActive() bool {
if !commandExists("systemctl") {
return false
}
return exec.Command("systemctl", "is-enabled", "citizen-launcher-self-update.timer").Run() == nil
}
func effectiveReleaseRepo() string {
if v := strings.TrimSpace(os.Getenv("CITIZEN_LAUNCHER_RELEASE_REPO")); v != "" {
return v
}
if data, err := os.ReadFile("/etc/citizen-launcher/release-repo"); err == nil {
if v := strings.TrimSpace(string(data)); v != "" {
return v
}
}
return releaseRepo
}
func (a *App) selfUpdateStatus(fetch bool) (SelfUpdateStatus, error) {
installed := installedPackageVersion()
st := SelfUpdateStatus{
Current: appVersion,
Installed: installed,
State: "not-checked",
Mode: a.installMode(),
Repository: effectiveReleaseRepo(),
}
if installed != "" && compareVersions(installed, appVersion) > 0 {
st.RestartNeeded = true
}
if !fetch {
if st.RestartNeeded {
st.State = "restart-required"
}
return st, nil
}
release, err := githubLatest(effectiveReleaseRepo())
st.CheckedAt = time.Now().Format(time.RFC3339)
if err != nil {
st.State = "check-failed"
st.Error = err.Error()
return st, err
}
latest := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v")
if latest == "" {
err := errors.New("latest release has no usable version tag")
st.State, st.Error = "check-failed", err.Error()
return st, err
}
st.Latest = latest
asset, err := a.releaseAssetForMode(release, latest, st.Mode)
if err != nil {
st.State = "asset-missing"
st.Error = err.Error()
return st, err
}
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"
case st.RestartNeeded:
st.State = "restart-required"
default:
st.State = "current"
}
return st, nil
}
func (a *App) installMode() string {
if installedPackageVersion() != "" && commandExists("dpkg-deb") {
return "deb"
}
return "user"
}
func installedPackageVersion() string {
if !commandExists("dpkg-query") {
return ""
}
cmd := exec.Command("dpkg-query", "-W", "-f=${Status}\n${Version}", "citizen-launcher")
out, err := cmd.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(lines[len(lines)-1])
}
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$`)
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)
}
func (a *App) applySelfUpdate(system, quiet bool) error {
st, err := a.selfUpdateStatus(true)
if err != nil {
return err
}
if st.State == "current" || st.State == "restart-required" {
if !quiet {
fmt.Printf("Citizen Launcher %s ist bereits installiert.\n", st.Installed)
}
return nil
}
if st.State != "available" {
return fmt.Errorf("self-update is not applicable in state %q", st.State)
}
release, err := githubLatest(effectiveReleaseRepo())
if err != nil {
return err
}
asset, err := a.releaseAssetForMode(release, st.Latest, st.Mode)
if err != nil {
return err
}
if !strings.HasPrefix(strings.ToLower(asset.Digest), "sha256:") {
return errors.New("release asset has no GitHub SHA-256 digest; refusing automatic update")
}
if st.Mode == "deb" {
if !system || os.Geteuid() != 0 {
if commandExists("pkexec") {
self := a.selfPath
if self == "" {
self = "/usr/bin/citizen-launcher"
}
cmd := exec.Command("pkexec", self, "self-update", "apply", "--system")
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 a.applyDebUpdate(asset, st.Latest, quiet)
}
return a.applyUserUpdate(asset, st.Latest, quiet)
}
func (a *App) applyDebUpdate(asset githubAsset, version string, quiet bool) error {
cache := "/var/cache/citizen-launcher"
if err := os.MkdirAll(cache, 0o755); err != nil {
return err
}
path := filepath.Join(cache, asset.Name)
tmp := path + ".new"
_ = os.Remove(tmp)
if err := download(asset.BrowserDownloadURL, tmp); err != nil {
return err
}
if err := verifyReleaseDigest(tmp, asset.Digest); err != nil {
_ = os.Remove(tmp)
return err
}
if err := verifyDebPackage(tmp, version); err != nil {
_ = os.Remove(tmp)
return err
}
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")
if quiet {
cmd.Stdout = a.logWriter()
cmd.Stderr = 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)
}
installed := installedPackageVersion()
if compareVersions(installed, version) < 0 {
return fmt.Errorf("package manager returned success but installed version is %q, expected at least %q", installed, version)
}
if !quiet {
fmt.Printf("Citizen Launcher wurde auf %s aktualisiert. Ein laufendes GUI verwendet die neue Version nach dem nächsten Neustart.\n", installed)
}
return nil
}
func verifyDebPackage(path, version string) error {
if !commandExists("dpkg-deb") {
return errors.New("dpkg-deb is unavailable")
}
cmd := exec.Command("dpkg-deb", "-f", path, "Package", "Version", "Architecture")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("cannot inspect Debian package: %s", formatCommandFailure(err, out))
}
values := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
k, v, ok := strings.Cut(line, ":")
if ok {
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)))
}
if pkg != "citizen-launcher" {
return fmt.Errorf("refusing package %q", pkg)
}
if compareVersions(pkgVersion, version) < 0 {
return fmt.Errorf("package version %q is older than release %q", pkgVersion, version)
}
if arch != "amd64" {
return fmt.Errorf("refusing architecture %q", arch)
}
return nil
}
func (a *App) applyUserUpdate(asset githubAsset, version string, quiet bool) error {
if a.selfPath == "" {
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")
}
if err := os.MkdirAll(a.cacheDir, 0o755); err != nil {
return err
}
archive := filepath.Join(a.cacheDir, asset.Name)
if err := download(asset.BrowserDownloadURL, archive); err != nil {
return err
}
if err := verifyReleaseDigest(archive, asset.Digest); err != nil {
return err
}
stage, err := os.MkdirTemp(a.cacheDir, "self-update-")
if err != nil {
return err
}
defer os.RemoveAll(stage)
if err := extractSingleBinaryTarGz(archive, stage); err != nil {
return err
}
candidate := filepath.Join(stage, "citizen-launcher")
out, err := exec.Command(candidate, "--version").CombinedOutput()
if err != nil || strings.TrimSpace(string(out)) != version {
return fmt.Errorf("downloaded launcher version check failed: %s", formatCommandFailure(err, out))
}
tmp := a.selfPath + ".new"
if err := copyFile(candidate, tmp, 0o755); err != nil {
return err
}
if err := os.Rename(tmp, a.selfPath); err != nil {
return err
}
if !quiet {
fmt.Printf("Citizen Launcher wurde auf %s aktualisiert.\n", version)
}
return nil
}
func extractSingleBinaryTarGz(path, dir string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
if filepath.Base(h.Name) != "citizen-launcher" || h.Typeflag != tar.TypeReg {
continue
}
target := filepath.Join(dir, "citizen-launcher")
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
if err != nil {
return err
}
_, copyErr := io.Copy(out, tr)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
return errors.New("tarball does not contain citizen-launcher")
}
func compareVersions(a, b string) int {
parse := func(v string) []int {
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
v = strings.SplitN(v, "-", 2)[0]
parts := strings.Split(v, ".")
out := make([]int, 3)
for i := 0; i < len(out) && i < len(parts); i++ {
n, _ := strconv.Atoi(parts[i])
out[i] = n
}
return out
}
aa, bb := parse(a), parse(b)
for i := range aa {
if aa[i] < bb[i] {
return -1
}
if aa[i] > bb[i] {
return 1
}
}
return 0
}
func printSelfUpdateKV(s SelfUpdateStatus) {
fmt.Printf("current=%s\n", s.Current)
fmt.Printf("installed=%s\n", s.Installed)
fmt.Printf("latest=%s\n", s.Latest)
fmt.Printf("state=%s\n", s.State)
fmt.Printf("mode=%s\n", s.Mode)
fmt.Printf("repository=%s\n", s.Repository)
fmt.Printf("asset=%s\n", s.Asset)
fmt.Printf("restart_needed=%t\n", s.RestartNeeded)
if s.Error != "" {
fmt.Printf("error=%s\n", s.Error)
}
}
@@ -0,0 +1,65 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestCompareVersions(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"0.9.2", "0.9.1", 1},
{"v1.0.0", "0.9.9", 1},
{"0.9.2", "0.9.2", 0},
{"0.9.1", "0.9.2", -1},
{"0.9.2-1", "0.9.2", 0},
}
for _, tc := range cases {
got := compareVersions(tc.a, tc.b)
if got != tc.want {
t.Fatalf("compareVersions(%q,%q)=%d want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestReleaseAssetForDeb(t *testing.T) {
a, _ := newApp()
r := githubRelease{TagName: "v0.9.2", Assets: []githubAsset{
{Name: "citizen-launcher-0.9.2-linux-amd64.tar.gz"},
{Name: "citizen-launcher_0.9.2_amd64.deb", Digest: "sha256:abc"},
}}
asset, err := a.releaseAssetForMode(r, "0.9.2", "deb")
if err != nil {
t.Fatal(err)
}
if asset.Name != "citizen-launcher_0.9.2_amd64.deb" {
t.Fatalf("wrong asset: %s", asset.Name)
}
}
func TestVerifyDebPackageMetadata(t *testing.T) {
if _, err := exec.LookPath("dpkg-deb"); err != nil {
t.Skip("dpkg-deb unavailable")
}
root := t.TempDir()
pkg := filepath.Join(root, "pkg")
if err := os.MkdirAll(filepath.Join(pkg, "DEBIAN"), 0o755); err != nil {
t.Fatal(err)
}
control := "Package: citizen-launcher\nVersion: 0.9.2\nArchitecture: amd64\nMaintainer: test\nDescription: test\n"
if err := os.WriteFile(filepath.Join(pkg, "DEBIAN", "control"), []byte(control), 0o644); err != nil {
t.Fatal(err)
}
deb := filepath.Join(root, "citizen-launcher_0.9.2_amd64.deb")
cmd := exec.Command("dpkg-deb", "--build", "--root-owner-group", pkg, deb)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("dpkg-deb: %v: %s", err, out)
}
if err := verifyDebPackage(deb, "0.9.2"); err != nil {
t.Fatal(err)
}
}
+9 -4
View File
@@ -33,6 +33,8 @@ const (
// fail under otherwise healthy Wine prefixes.
var basePrefixWinetricksVerbs = []string{"arial", "tahoma", "win11"}
const wineFileAssociationsKey = "HKEY_CURRENT_USER\\Software\\Wine\\FileOpenAssociations"
type GameConfig struct {
Prefix string `json:"prefix"`
GameDir string `json:"game_dir"`
@@ -697,11 +699,14 @@ func (a *App) ensurePrefixComponents(gc GameConfig) error {
runner, _ := a.currentRunner()
wine := filepath.Join(runner, "bin", "wine")
reg := exec.Command(wine, "reg", "add", `HKEY_CURRENT_USER\\Software\\Wine\\FileOpenAssociations`, `/v`, `Enable`, `/d`, `N`, `/f`)
// This is a convenience tweak only: prevent Wine from creating host file
// associations. It must never make the Star Citizen installation fail.
// Use a normal Go string so Wine receives single registry separators.
reg := exec.Command(wine, "reg", "add", wineFileAssociationsKey, "/v", "Enable", "/t", "REG_SZ", "/d", "N", "/f")
reg.Env = env
out, err := reg.CombinedOutput()
if err != nil {
return fmt.Errorf("Wine Registry: %s", formatCommandFailure(err, out))
out, regErr := reg.CombinedOutput()
if regErr != nil {
a.logf("non-fatal Wine registry association tweak warning: %s", formatCommandFailure(regErr, out))
}
_ = writeMeta(filepath.Join(a.vendorDir, "winetricks", "meta.json"), componentMeta{Version: tag, Updated: time.Now().Format(time.RFC3339)})
return nil
@@ -56,3 +56,12 @@ func TestBasePrefixDoesNotRequirePowerShell(t *testing.T) {
}
}
}
func TestWineRegistryKeyUsesSingleSeparators(t *testing.T) {
if strings.Contains(wineFileAssociationsKey, `\\`) {
t.Fatalf("registry key contains doubled separators: %q", wineFileAssociationsKey)
}
if wineFileAssociationsKey != `HKEY_CURRENT_USER\Software\Wine\FileOpenAssociations` {
t.Fatalf("unexpected registry key: %q", wineFileAssociationsKey)
}
}
+2 -2
View File
@@ -4,8 +4,8 @@ function cls(el,state){el.classList.remove('good','bad','warn');if(state)el.clas
function txt(id,v,c){const e=$(id);const value=(v===undefined||v===null||v==='')?'—':String(v);e.textContent=value;e.title=value;cls(e,c)}
function primaryFor(h){if(h==='ready')return['STAR CITIZEN STARTEN','launch'];if(h==='hardware-blocked')return['HARDWARE-DETAILS','doctor'];if(h==='repair'||h==='launcher-repair')return['AUTOMATISCH REPARIEREN','repair'];return['EINRICHTEN & STARTKLAR MACHEN','setup']}
function setHeroHealth(h){$('hero').dataset.health=h||'setup'}
async function refresh(){try{const s=await api('/api/status');current=s;$('version').textContent='v'+s.version;$('platform').textContent=[s.platform.name,s.platform.desktop].filter(Boolean).join(' · ');$('distro').textContent=s.platform.name;$('session').textContent=s.platform.session||'—';const g=s.game;setHeroHealth(g.health);let head='READY FOR SETUP',sub='Citizen Launcher übernimmt Wine, DXVK, Prefix und RSI Launcher.';if(g.health==='ready'){head='FLIGHT READY';sub='Star Citizen ist startbereit. Autopilot hält den Gaming-Stack aktuell.'}else if(g.health==='hardware-blocked'){head='HARDWARE CHECK';sub=g.hardware_reason||'Die Hardware erfüllt eine Voraussetzung nicht.'}else if(g.health==='repair'||g.health==='launcher-repair'){head='REPAIR AVAILABLE';sub='Ein reparierbarer Zustand wurde erkannt. Die Reparatur verändert keine Spieldaten.'}else if(g.health==='install-game'){head='LAUNCHER READY';sub='Der RSI Launcher ist bereit. Melde dich an und installiere Star Citizen.'}$('headline').textContent=head;$('subline').textContent=sub;const [label,act]=primaryFor(g.health);$('primary').textContent=label;$('primary').dataset.action=act;txt('gpu',g.gpu||'nicht eindeutig erkannt',g.hardware==='blocked'?'bad':'good');txt('vulkan',g.vulkan,g.vulkan==='blocked'?'bad':g.vulkan==='ready'?'good':'warn');txt('ram',g.ram_gib+' GiB',g.ram_gib>=16?'good':'warn');txt('wine',g.wine_version||'wird eingerichtet',g.wine_version?'good':'warn');txt('dxvk',g.dxvk_version||'wird eingerichtet',g.dxvk_version?'good':'warn');txt('rsi',g.launcher_state==='ready'?'bereit':'Setup',g.launcher_state==='ready'?'good':'warn');txt('autopilot',g.autopilot?'aktiv':'aus',g.autopilot?'good':'warn');$('raw').textContent=JSON.stringify(s,null,2)}catch(e){$('headline').textContent='BACKEND ERROR';$('subline').textContent=e.message}}
async function refresh(){try{const s=await api('/api/status');current=s;$('version').textContent='v'+s.version;$('platform').textContent=[s.platform.name,s.platform.desktop].filter(Boolean).join(' · ');$('distro').textContent=s.platform.name;$('session').textContent=s.platform.session||'—';txt('packageUpdates',s.package_auto_update?'automatisch':'manuell',s.package_auto_update?'good':'warn');const ub=$('updateBanner');if(s.restart_required){ub.classList.remove('hidden');$('updateText').textContent='Installiert ist '+(s.installed_version||'eine neuere Version')+', dieses Fenster läuft noch mit '+s.version+'.'}else{ub.classList.add('hidden')}const g=s.game;setHeroHealth(g.health);let head='READY FOR SETUP',sub='Citizen Launcher übernimmt Wine, DXVK, Prefix und RSI Launcher.';if(g.health==='ready'){head='FLIGHT READY';sub='Star Citizen ist startbereit. Autopilot hält den Gaming-Stack aktuell.'}else if(g.health==='hardware-blocked'){head='HARDWARE CHECK';sub=g.hardware_reason||'Die Hardware erfüllt eine Voraussetzung nicht.'}else if(g.health==='repair'||g.health==='launcher-repair'){head='REPAIR AVAILABLE';sub='Ein reparierbarer Zustand wurde erkannt. Die Reparatur verändert keine Spieldaten.'}else if(g.health==='install-game'){head='LAUNCHER READY';sub='Der RSI Launcher ist bereit. Melde dich an und installiere Star Citizen.'}$('headline').textContent=head;$('subline').textContent=sub;const [label,act]=primaryFor(g.health);$('primary').textContent=label;$('primary').dataset.action=act;txt('gpu',g.gpu||'nicht eindeutig erkannt',g.hardware==='blocked'?'bad':'good');txt('vulkan',g.vulkan,g.vulkan==='blocked'?'bad':g.vulkan==='ready'?'good':'warn');txt('ram',g.ram_gib+' GiB',g.ram_gib>=16?'good':'warn');txt('wine',g.wine_version||'wird eingerichtet',g.wine_version?'good':'warn');txt('dxvk',g.dxvk_version||'wird eingerichtet',g.dxvk_version?'good':'warn');txt('rsi',g.launcher_state==='ready'?'bereit':'Setup',g.launcher_state==='ready'?'good':'warn');txt('autopilot',g.autopilot?'aktiv':'aus',g.autopilot?'good':'warn');$('raw').textContent=JSON.stringify(s,null,2)}catch(e){$('headline').textContent='BACKEND ERROR';$('subline').textContent=e.message}}
function showJob(state,title,message,raw=''){const box=$('job');box.classList.remove('hidden','error','done');if(state==='error')box.classList.add('error');if(state==='done')box.classList.add('done');$('jobTitle').textContent=title;$('jobText').textContent=message||'';const spin=box.querySelector('.spinner');const symbol=$('jobSymbol');spin.classList.toggle('hidden',state!=='running');symbol.classList.toggle('hidden',state==='running');symbol.textContent=state==='done'?'✓':'!';const details=$('jobDetails');if(raw&&raw!==message){details.classList.remove('hidden');$('jobRaw').textContent=raw}else{details.classList.add('hidden');$('jobRaw').textContent=''}}
async function action(name){try{if(name==='launch'){await api('/api/action/launch',{method:'POST'});showJob('done','RSI Launcher gestartet','Der Launcher wurde gestartet.');return}const j=await api('/api/action/'+name,{method:'POST'});showJob('running','Aktion: '+name,'Wird ausgeführt …');poll(j.id)}catch(e){showJob('error','Aktion konnte nicht gestartet werden',e.message,e.message)}}
async function action(name){try{if(name==='restart'){await api('/api/action/restart',{method:'POST'});showJob('done','Launcher wird neu gestartet','Die neue Version wird geöffnet …');return}if(name==='launch'){await api('/api/action/launch',{method:'POST'});showJob('done','RSI Launcher gestartet','Der Launcher wurde gestartet.');return}const j=await api('/api/action/'+name,{method:'POST'});showJob('running','Aktion: '+name,'Wird ausgeführt …');poll(j.id)}catch(e){showJob('error','Aktion konnte nicht gestartet werden',e.message,e.message)}}
async function poll(id){try{const j=await api('/api/job/'+id);if(j.state==='running'){setTimeout(()=>poll(id),800);return}if(j.state==='done')showJob('done','Abgeschlossen',j.message||'Die Aktion wurde erfolgreich abgeschlossen.');else showJob('error','Aktion fehlgeschlagen',j.message||'Die Aktion konnte nicht abgeschlossen werden.',j.error||'');await refresh()}catch(e){showJob('error','Statusfehler','Der Aktionsstatus konnte nicht gelesen werden.',e.message)}}
document.addEventListener('click',e=>{const button=e.target.closest('[data-action],[data-open]');if(!button)return;const a=button.dataset.action;if(a)action(a);const o=button.dataset.open;if(o)api('/api/open/'+o,{method:'POST'});});$('toggle').onclick=()=>$('advanced').classList.toggle('hidden');$('primary').onclick=()=>action($('primary').dataset.action);refresh();setInterval(refresh,10000);
+2 -1
View File
@@ -3,11 +3,12 @@
<title>Citizen Launcher</title><link rel="stylesheet" href="__BASE__/assets/style.css"></head>
<body><div class="scan"></div><main>
<header><div class="mark">✦</div><div class="brand"><div class="eyebrow">LINUX FLIGHT SYSTEM</div><h1>Citizen Launcher</h1><p id="platform">Linux wird erkannt …</p></div><div class="version" id="version"></div></header>
<section class="update-banner hidden" id="updateBanner"><div><b>Neue Version installiert</b><p id="updateText">Citizen Launcher wurde im Hintergrund aktualisiert.</p></div><button data-action="restart">LAUNCHER NEU STARTEN</button></section>
<section class="hero" id="hero"><div class="hero-copy"><div class="eyebrow">SYSTEM STATE</div><h2 id="headline">SYSTEM CHECK</h2><p id="subline">Hardware und Gaming-Stack werden geprüft.</p></div><button class="primary" id="primary">PRÜFEN …</button></section>
<section class="dashboard">
<article class="card hardware"><h3>Hardware</h3><div class="metric"><span>GPU</span><b id="gpu">—</b></div><div class="metric"><span>Vulkan</span><b id="vulkan">—</b></div><div class="metric"><span>RAM</span><b id="ram">—</b></div></article>
<article class="card"><h3>Gaming Stack</h3><div class="metric"><span>Wine</span><b id="wine">—</b></div><div class="metric"><span>DXVK</span><b id="dxvk">—</b></div><div class="metric"><span>Launcher</span><b id="rsi">—</b></div></article>
<article class="card"><h3>Autopilot</h3><div class="metric"><span>Wartung</span><b id="autopilot">—</b></div><div class="metric"><span>Distribution</span><b id="distro">—</b></div><div class="metric"><span>Session</span><b id="session">—</b></div></article>
<article class="card"><h3>Autopilot</h3><div class="metric"><span>Gaming-Stack</span><b id="autopilot">—</b></div><div class="metric"><span>Launcher-Updates</span><b id="packageUpdates">—</b></div><div class="metric"><span>Distribution</span><b id="distro">—</b></div><div class="metric"><span>Session</span><b id="session">—</b></div></article>
</section>
<section class="actions"><button data-action="maintain"><strong>Stack aktualisieren</strong><small>Wine, DXVK & Launcher prüfen</small></button><button data-action="repair"><strong>Automatisch reparieren</strong><small>Setup und Launcher heilen</small></button><button data-action="doctor"><strong>Wine-Selbsttest</strong><small>Prefix isoliert prüfen</small></button><button data-action="support"><strong>Support-Paket</strong><small>Diagnose für Hilfe erstellen</small></button></section>
<section class="job hidden" id="job"><div class="job-icon"><div class="spinner"></div><span class="job-symbol hidden" id="jobSymbol">!</span></div><div class="job-body"><b id="jobTitle">Aktion läuft</b><p id="jobText"></p><details class="job-details hidden" id="jobDetails"><summary>Technische Details</summary><pre id="jobRaw"></pre></details></div></section>
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
# Automatic launcher updates
## Debian / Ubuntu / Mint
The `.deb` installs a root systemd timer:
- `citizen-launcher-self-update.timer`
- checks on boot and every six hours
- reads the latest GitHub Release from the configured repository
- accepts only a newer `citizen-launcher_<version>_amd64.deb`
- requires GitHub's `sha256:` release asset digest
- verifies the downloaded package name, version, and architecture with `dpkg-deb`
- installs via `apt-get` with the dpkg lock timeout enabled
The release repository is stored in:
```text
/etc/citizen-launcher/release-repo
```
Default for this project:
```text
sendnwv/omarchy-sc
```
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**.
## Generic user install
The Autopilot user timer checks GitHub releases. For a `~/.local` installation it
can replace the launcher binary atomically from the verified Linux tarball without
root privileges.
## Release publishing
`.github/workflows/release.yml` builds and publishes the `.deb`, generic tarball and
SHA256SUMS whenever a `v<VERSION>` tag is pushed. GitHub computes an immutable asset
digest that the Debian self-updater verifies before installation.
+10 -3
View File
@@ -1,10 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="0.9.1"; ARCH="amd64"; WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")"; ARCH="amd64"; WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
PKG="$WORK/citizen-launcher_${VERSION}_${ARCH}"
mkdir -p "$PKG/DEBIAN" "$PKG/usr/bin" "$PKG/usr/share/applications"
mkdir -p "$PKG/DEBIAN" "$PKG/usr/bin" "$PKG/usr/share/applications" "$PKG/usr/lib/systemd/system" "$PKG/etc/citizen-launcher"
install -m755 "$ROOT/backend/bin/citizen-launcher" "$PKG/usr/bin/citizen-launcher"
install -m644 "$ROOT/packaging/systemd/citizen-launcher-self-update.service" "$PKG/usr/lib/systemd/system/citizen-launcher-self-update.service"
install -m644 "$ROOT/packaging/systemd/citizen-launcher-self-update.timer" "$PKG/usr/lib/systemd/system/citizen-launcher-self-update.timer"
printf '%s\n' "${CITIZEN_LAUNCHER_RELEASE_REPO:-sendnwv/omarchy-sc}" > "$PKG/etc/citizen-launcher/release-repo"
install -m755 "$ROOT/packaging/postinst" "$PKG/DEBIAN/postinst"
install -m755 "$ROOT/packaging/prerm" "$PKG/DEBIAN/prerm"
install -m755 "$ROOT/packaging/postrm" "$PKG/DEBIAN/postrm"
cat > "$PKG/DEBIAN/control" <<EOF
Package: citizen-launcher
Version: $VERSION
@@ -12,7 +19,7 @@ Section: games
Priority: optional
Architecture: $ARCH
Maintainer: Citizen Launcher Project
Depends: ca-certificates, tar, xdg-utils, curl, cabextract, unzip, xz-utils
Depends: ca-certificates, tar, xdg-utils, curl, cabextract, unzip, xz-utils, apt
Recommends: pciutils, vulkan-tools
Description: Star Citizen setup, launcher and self-maintaining gaming stack for Linux
Distro-neutral Citizen Launcher with Wine/DXVK/RSI management and local GUI.
+2 -1
View File
@@ -2,4 +2,5 @@
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
mkdir -p "$ROOT/dist"
tar -C "$ROOT/backend/bin" -czf "$ROOT/dist/citizen-launcher-0.9.1-linux-amd64.tar.gz" citizen-launcher
VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")"
tar -C "$ROOT/backend/bin" -czf "$ROOT/dist/citizen-launcher-${VERSION}-linux-amd64.tar.gz" citizen-launcher
+1 -1
View File
@@ -1,5 +1,5 @@
Name: citizen-launcher
Version: 0.9.1
Version: 0.9.2
Release: 1%{?dist}
Summary: Star Citizen launcher and self-maintaining Wine stack for Linux
License: MIT
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -e
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl enable --now citizen-launcher-self-update.timer >/dev/null 2>&1 || true
fi
exit 0
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
exit 0
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
if [ "$1" = remove ] || [ "$1" = deconfigure ]; then
if command -v systemctl >/dev/null 2>&1; then
systemctl disable --now citizen-launcher-self-update.timer >/dev/null 2>&1 || true
fi
fi
exit 0
@@ -0,0 +1,16 @@
[Unit]
Description=Citizen Launcher automatic package update
Documentation=https://github.com/sendnwv/omarchy-sc
After=network-online.target
Wants=network-online.target
ConditionPathExists=/usr/bin/citizen-launcher
[Service]
Type=oneshot
ExecStart=/usr/bin/citizen-launcher self-update apply --system --quiet
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
PrivateTmp=true
ProtectHome=true
UMask=0022
@@ -0,0 +1,12 @@
[Unit]
Description=Check Citizen Launcher package updates automatically
[Timer]
OnBootSec=5min
OnUnitActiveSec=6h
RandomizedDelaySec=20min
Persistent=true
Unit=citizen-launcher-self-update.service
[Install]
WantedBy=timers.target
+1 -1
View File
@@ -3,7 +3,7 @@ set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
for f in build.sh install.sh install-omarchy.sh uninstall.sh packaging/build-deb.sh packaging/build-tarball.sh integrations/omarchy/citizenctl; do bash -n "$ROOT/$f"; done
(cd "$ROOT/backend" && gofmt -w cmd/citizen-launcher/*.go && go test ./... && go vet ./... && ./build.sh)
"$ROOT/backend/bin/citizen-launcher" --version | grep -qx '0.9.1'
"$ROOT/backend/bin/citizen-launcher" --version | grep -qx "$(tr -d '[:space:]' < "$ROOT/VERSION")"
"$ROOT/backend/bin/citizen-launcher" platform --json | grep -q '"id"'
# Ensure GUI assets are embedded by looking for a distinctive string in binary.
grep -a -q 'FLIGHT READY' "$ROOT/backend/bin/citizen-launcher"