diff --git a/README.md b/README.md index 9d83820..6a9542e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a6212a1..f3e2510 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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__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 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..2003b63 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.9.2 diff --git a/backend/bin/citizen-launcher b/backend/bin/citizen-launcher index 5cecb9b..b969784 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 80294ac..3eb6afb 100644 --- a/backend/build.sh +++ b/backend/build.sh @@ -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 diff --git a/backend/cmd/citizen-launcher/gui.go b/backend/cmd/citizen-launcher/gui.go index 7563b07..0945ed8 100644 --- a/backend/cmd/citizen-launcher/gui.go +++ b/backend/cmd/citizen-launcher/gui.go @@ -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) diff --git a/backend/cmd/citizen-launcher/main.go b/backend/cmd/citizen-launcher/main.go index 204a1de..45d847d 100644 --- a/backend/cmd/citizen-launcher/main.go +++ b/backend/cmd/citizen-launcher/main.go @@ -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 { diff --git a/backend/cmd/citizen-launcher/selfupdate.go b/backend/cmd/citizen-launcher/selfupdate.go new file mode 100644 index 0000000..074381d --- /dev/null +++ b/backend/cmd/citizen-launcher/selfupdate.go @@ -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) + } +} diff --git a/backend/cmd/citizen-launcher/selfupdate_test.go b/backend/cmd/citizen-launcher/selfupdate_test.go new file mode 100644 index 0000000..51c587c --- /dev/null +++ b/backend/cmd/citizen-launcher/selfupdate_test.go @@ -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) + } +} diff --git a/backend/cmd/citizen-launcher/stack.go b/backend/cmd/citizen-launcher/stack.go index b260e63..4b8e43c 100644 --- a/backend/cmd/citizen-launcher/stack.go +++ b/backend/cmd/citizen-launcher/stack.go @@ -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 diff --git a/backend/cmd/citizen-launcher/stack_test.go b/backend/cmd/citizen-launcher/stack_test.go index b5a8030..307d677 100644 --- a/backend/cmd/citizen-launcher/stack_test.go +++ b/backend/cmd/citizen-launcher/stack_test.go @@ -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) + } +} diff --git a/backend/cmd/citizen-launcher/web/app.js b/backend/cmd/citizen-launcher/web/app.js index f9a28b2..949bed1 100644 --- a/backend/cmd/citizen-launcher/web/app.js +++ b/backend/cmd/citizen-launcher/web/app.js @@ -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); \ No newline at end of file diff --git a/backend/cmd/citizen-launcher/web/index.html b/backend/cmd/citizen-launcher/web/index.html index 68c6d8d..aab4130 100644 --- a/backend/cmd/citizen-launcher/web/index.html +++ b/backend/cmd/citizen-launcher/web/index.html @@ -3,11 +3,12 @@ Citizen Launcher
✦
LINUX FLIGHT SYSTEM

Citizen Launcher

Linux wird erkannt …

+
SYSTEM STATE

SYSTEM CHECK

Hardware und Gaming-Stack werden geprüft.

Hardware

GPU—
Vulkan—
RAM—

Gaming Stack

Wine—
DXVK—
Launcher—
-

Autopilot

Wartung—
Distribution—
Session—
+

Autopilot

Gaming-Stack—
Launcher-Updates—
Distribution—
Session—
diff --git a/backend/cmd/citizen-launcher/web/style.css b/backend/cmd/citizen-launcher/web/style.css index 40a378f..a1cc4fb 100644 --- a/backend/cmd/citizen-launcher/web/style.css +++ b/backend/cmd/citizen-launcher/web/style.css @@ -1 +1 @@ -:root{color-scheme:dark;--bg:#060b11;--panel:#0a1721;--panel2:#0d202d;--line:#183d50;--line2:#2b6f8d;--cyan:#54d8ff;--green:#70e7ad;--amber:#ffd06b;--red:#ff8398;--text:#edf8fc;--muted:#8da7b5;--shadow:#0008}*{box-sizing:border-box}html{background:var(--bg)}body{margin:0;min-height:100vh;color:var(--text);font:15px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at 68% -15%,#14354d 0,transparent 40%),linear-gradient(145deg,#050a0f,#09151f 60%,#06111a)}.scan{position:fixed;inset:0;pointer-events:none;opacity:.07;background:repeating-linear-gradient(0deg,transparent 0 4px,#66dbff12 5px)}main{width:min(1280px,calc(100% - 40px));margin:26px auto 48px;position:relative}header{display:flex;align-items:center;gap:15px;padding:0 3px 20px;min-width:0}.mark{width:52px;height:52px;flex:0 0 52px;display:grid;place-items:center;border:1px solid var(--cyan);border-radius:14px;color:var(--cyan);font-size:24px;background:#0c2030aa;box-shadow:0 0 25px #4bd5ff1d inset}.brand{min-width:0}.eyebrow{font-size:10px;letter-spacing:.2em;color:var(--cyan);font-weight:850}h1,h2,h3,p{margin:0}h1{font-size:24px;letter-spacing:.015em}header p{color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.version{margin-left:auto;align-self:flex-start;padding-top:13px;color:var(--muted);font:13px ui-monospace,SFMono-Regular,Consolas,monospace}.hero,.card,.job,.details{border:1px solid var(--line);background:linear-gradient(155deg,#0c1b26ed,#08131bed);box-shadow:0 20px 60px var(--shadow)}.hero{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:26px;padding:26px;border-color:var(--line2);border-radius:18px;min-width:0}.hero-copy{min-width:0}.hero h2{font-size:clamp(28px,3vw,38px);line-height:1.05;letter-spacing:.045em;margin:7px 0 9px}.hero p{color:var(--muted);max-width:740px}.primary{min-width:250px;padding:16px 24px!important;font-weight:900;letter-spacing:.075em;border-color:var(--cyan)!important;color:#06131b!important;background:linear-gradient(135deg,#7de5ff,#45c8f7)!important;box-shadow:0 8px 30px #37c7f522}.dashboard{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,.78fr) minmax(0,.9fr);gap:14px;margin-top:14px;align-items:stretch}.card{padding:19px;border-radius:17px;min-width:0;overflow:hidden}.card h3{color:var(--cyan);font-size:12px;letter-spacing:.14em;text-transform:uppercase;margin-bottom:10px}.metric{display:grid;grid-template-columns:minmax(88px,.48fr) minmax(0,1fr);align-items:start;gap:12px;padding:9px 0;border-bottom:1px solid #143445;min-width:0}.metric:last-child{border:0}.metric span{color:var(--muted)}.metric b{min-width:0;text-align:right;overflow-wrap:anywhere;word-break:break-word}.actions{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:14px 0}.actions button{display:flex;flex-direction:column;align-items:flex-start;text-align:left;gap:2px;min-width:0}.actions strong{font-weight:700}.actions small{color:var(--muted);font-size:11px;white-space:normal}button{border:1px solid #294e63;background:#0c1b25;color:var(--text);padding:12px 15px;border-radius:10px;cursor:pointer;font:inherit;transition:border-color .15s,background .15s,transform .15s}button:hover{border-color:var(--cyan);background:#102837}button:active{transform:translateY(1px)}.job{display:grid;grid-template-columns:auto minmax(0,1fr);gap:13px;align-items:start;padding:16px 17px;margin:14px 0;border-radius:15px}.job.error{border-color:#7e3142;background:linear-gradient(155deg,#23131a,#0b141b)}.job.done{border-color:#285e4b}.job-icon{width:26px;height:26px;display:grid;place-items:center}.job-body{min-width:0}.job p{color:var(--muted);margin-top:4px;white-space:pre-wrap;overflow-wrap:anywhere}.job-symbol{font-size:18px;font-weight:900}.job.error .job-symbol{color:var(--red)}.job.done .job-symbol{color:var(--green)}.spinner{width:21px;height:21px;border:2px solid #285165;border-top-color:var(--cyan);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.job-details{margin-top:10px}.job-details summary{cursor:pointer;color:#b8d0db;font-size:12px}.job-details pre{margin-top:8px}.details{padding:10px 13px;border-radius:15px}.details>button{width:100%;background:transparent;border:0;color:var(--muted)}pre{white-space:pre-wrap;overflow-wrap:anywhere;color:#a9c7d4;background:#050f16;padding:13px;border:1px solid #112e3d;border-radius:10px;max-height:330px;overflow:auto;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}.small-actions{display:flex;flex-wrap:wrap;gap:8px}.hidden{display:none!important}.bad{color:var(--red)!important}.warn{color:var(--amber)!important}.good{color:var(--green)!important}footer{text-align:center;color:#597584;font-size:11px;padding:22px 8px}@media(max-width:1080px){main{width:min(980px,calc(100% - 32px))}.dashboard{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard .hardware{grid-column:1/-1}.actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){main{width:calc(100% - 22px);margin-top:14px}.hero{grid-template-columns:1fr;padding:20px}.primary{width:100%;min-width:0}.dashboard{grid-template-columns:1fr}.dashboard .hardware{grid-column:auto}.actions{grid-template-columns:1fr}header{padding-bottom:14px}.version{font-size:11px}.metric{grid-template-columns:minmax(80px,.42fr) minmax(0,1fr)}} \ No newline at end of file +:root{color-scheme:dark;--bg:#060b11;--panel:#0a1721;--panel2:#0d202d;--line:#183d50;--line2:#2b6f8d;--cyan:#54d8ff;--green:#70e7ad;--amber:#ffd06b;--red:#ff8398;--text:#edf8fc;--muted:#8da7b5;--shadow:#0008}*{box-sizing:border-box}html{background:var(--bg)}body{margin:0;min-height:100vh;color:var(--text);font:15px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at 68% -15%,#14354d 0,transparent 40%),linear-gradient(145deg,#050a0f,#09151f 60%,#06111a)}.scan{position:fixed;inset:0;pointer-events:none;opacity:.07;background:repeating-linear-gradient(0deg,transparent 0 4px,#66dbff12 5px)}main{width:min(1280px,calc(100% - 40px));margin:26px auto 48px;position:relative}header{display:flex;align-items:center;gap:15px;padding:0 3px 20px;min-width:0}.mark{width:52px;height:52px;flex:0 0 52px;display:grid;place-items:center;border:1px solid var(--cyan);border-radius:14px;color:var(--cyan);font-size:24px;background:#0c2030aa;box-shadow:0 0 25px #4bd5ff1d inset}.brand{min-width:0}.eyebrow{font-size:10px;letter-spacing:.2em;color:var(--cyan);font-weight:850}h1,h2,h3,p{margin:0}h1{font-size:24px;letter-spacing:.015em}header p{color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.version{margin-left:auto;align-self:flex-start;padding-top:13px;color:var(--muted);font:13px ui-monospace,SFMono-Regular,Consolas,monospace}.hero,.card,.job,.details{border:1px solid var(--line);background:linear-gradient(155deg,#0c1b26ed,#08131bed);box-shadow:0 20px 60px var(--shadow)}.hero{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:26px;padding:26px;border-color:var(--line2);border-radius:18px;min-width:0}.hero-copy{min-width:0}.hero h2{font-size:clamp(28px,3vw,38px);line-height:1.05;letter-spacing:.045em;margin:7px 0 9px}.hero p{color:var(--muted);max-width:740px}.primary{min-width:250px;padding:16px 24px!important;font-weight:900;letter-spacing:.075em;border-color:var(--cyan)!important;color:#06131b!important;background:linear-gradient(135deg,#7de5ff,#45c8f7)!important;box-shadow:0 8px 30px #37c7f522}.dashboard{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,.78fr) minmax(0,.9fr);gap:14px;margin-top:14px;align-items:stretch}.card{padding:19px;border-radius:17px;min-width:0;overflow:hidden}.card h3{color:var(--cyan);font-size:12px;letter-spacing:.14em;text-transform:uppercase;margin-bottom:10px}.metric{display:grid;grid-template-columns:minmax(88px,.48fr) minmax(0,1fr);align-items:start;gap:12px;padding:9px 0;border-bottom:1px solid #143445;min-width:0}.metric:last-child{border:0}.metric span{color:var(--muted)}.metric b{min-width:0;text-align:right;overflow-wrap:anywhere;word-break:break-word}.actions{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:14px 0}.actions button{display:flex;flex-direction:column;align-items:flex-start;text-align:left;gap:2px;min-width:0}.actions strong{font-weight:700}.actions small{color:var(--muted);font-size:11px;white-space:normal}button{border:1px solid #294e63;background:#0c1b25;color:var(--text);padding:12px 15px;border-radius:10px;cursor:pointer;font:inherit;transition:border-color .15s,background .15s,transform .15s}button:hover{border-color:var(--cyan);background:#102837}button:active{transform:translateY(1px)}.job{display:grid;grid-template-columns:auto minmax(0,1fr);gap:13px;align-items:start;padding:16px 17px;margin:14px 0;border-radius:15px}.job.error{border-color:#7e3142;background:linear-gradient(155deg,#23131a,#0b141b)}.job.done{border-color:#285e4b}.job-icon{width:26px;height:26px;display:grid;place-items:center}.job-body{min-width:0}.job p{color:var(--muted);margin-top:4px;white-space:pre-wrap;overflow-wrap:anywhere}.job-symbol{font-size:18px;font-weight:900}.job.error .job-symbol{color:var(--red)}.job.done .job-symbol{color:var(--green)}.spinner{width:21px;height:21px;border:2px solid #285165;border-top-color:var(--cyan);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.job-details{margin-top:10px}.job-details summary{cursor:pointer;color:#b8d0db;font-size:12px}.job-details pre{margin-top:8px}.details{padding:10px 13px;border-radius:15px}.details>button{width:100%;background:transparent;border:0;color:var(--muted)}pre{white-space:pre-wrap;overflow-wrap:anywhere;color:#a9c7d4;background:#050f16;padding:13px;border:1px solid #112e3d;border-radius:10px;max-height:330px;overflow:auto;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}.small-actions{display:flex;flex-wrap:wrap;gap:8px}.hidden{display:none!important}.bad{color:var(--red)!important}.warn{color:var(--amber)!important}.good{color:var(--green)!important}footer{text-align:center;color:#597584;font-size:11px;padding:22px 8px}@media(max-width:1080px){main{width:min(980px,calc(100% - 32px))}.dashboard{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard .hardware{grid-column:1/-1}.actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){main{width:calc(100% - 22px);margin-top:14px}.hero{grid-template-columns:1fr;padding:20px}.primary{width:100%;min-width:0}.dashboard{grid-template-columns:1fr}.dashboard .hardware{grid-column:auto}.actions{grid-template-columns:1fr}header{padding-bottom:14px}.version{font-size:11px}.metric{grid-template-columns:minmax(80px,.42fr) minmax(0,1fr)}}.update-banner{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:0 0 14px;padding:14px 16px;border:1px solid #2a775b;border-radius:14px;background:linear-gradient(135deg,#0d2a20,#0a1820);box-shadow:0 12px 35px #0006}.update-banner b{color:var(--green);font-size:14px}.update-banner p{color:var(--muted);margin-top:2px}.update-banner button{white-space:nowrap;border-color:var(--green)}@media(max-width:720px){.update-banner{align-items:stretch;flex-direction:column}.update-banner button{width:100%}} diff --git a/dist/citizen-launcher-0.9.2-linux-amd64.tar.gz b/dist/citizen-launcher-0.9.2-linux-amd64.tar.gz new file mode 100644 index 0000000..d2f4e8f Binary files /dev/null and b/dist/citizen-launcher-0.9.2-linux-amd64.tar.gz differ diff --git a/dist/citizen-launcher_0.9.2_amd64.deb b/dist/citizen-launcher_0.9.2_amd64.deb new file mode 100644 index 0000000..eeb3ad9 Binary files /dev/null and b/dist/citizen-launcher_0.9.2_amd64.deb differ diff --git a/packaging/SELF_UPDATE.md b/packaging/SELF_UPDATE.md new file mode 100644 index 0000000..6821000 --- /dev/null +++ b/packaging/SELF_UPDATE.md @@ -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__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` tag is pushed. GitHub computes an immutable asset +digest that the Debian self-updater verifies before installation. diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 4ccf7a2..4b6f11c 100644 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -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" </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 diff --git a/packaging/postrm b/packaging/postrm new file mode 100644 index 0000000..c5b0304 --- /dev/null +++ b/packaging/postrm @@ -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 diff --git a/packaging/prerm b/packaging/prerm new file mode 100644 index 0000000..1f62ba5 --- /dev/null +++ b/packaging/prerm @@ -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 diff --git a/packaging/systemd/citizen-launcher-self-update.service b/packaging/systemd/citizen-launcher-self-update.service new file mode 100644 index 0000000..69bb4cc --- /dev/null +++ b/packaging/systemd/citizen-launcher-self-update.service @@ -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 diff --git a/packaging/systemd/citizen-launcher-self-update.timer b/packaging/systemd/citizen-launcher-self-update.timer new file mode 100644 index 0000000..8e16e69 --- /dev/null +++ b/packaging/systemd/citizen-launcher-self-update.timer @@ -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 diff --git a/tests/verify.sh b/tests/verify.sh index e582317..e9c271b 100644 --- a/tests/verify.sh +++ b/tests/verify.sh @@ -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"