package main import ( "archive/tar" "compress/gzip" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "syscall" "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"` } type installedPackage struct { Mode string Version string } func packageAutoUpdateActive() bool { if !commandExists("systemctl") { return false } 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_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$`) 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 // system updater. Root trusts only the root-owned config file or compiled repo. if os.Geteuid() != 0 { if v := strings.TrimSpace(os.Getenv("CITIZEN_LAUNCHER_RELEASE_REPO")); validReleaseRepo(v) { return v } } if data, err := os.ReadFile("/etc/citizen-launcher/release-repo"); err == nil { trusted := true if os.Geteuid() == 0 { if fi, statErr := os.Stat("/etc/citizen-launcher/release-repo"); statErr != nil { trusted = false } else if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 || fi.Mode().Perm()&0o022 != 0 { trusted = false } } if trusted { if v := strings.TrimSpace(string(data)); validReleaseRepo(v) { return v } } } if validReleaseRepo(releaseRepo) { return releaseRepo } 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) { 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: mode, 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 := launcherLatestRelease(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 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() return st, err } st.Asset = asset.Name st.Digest = asset.Digest 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 { pkg := installedPackageInfo() if pkg.Version == "" || !isSystemExecutable(a.selfPath) { return "user" } p := detectPlatform() if p.Immutable { return "system-managed" } return pkg.Mode } 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 "" } out, err := exec.Command(path, "--version").CombinedOutput() if err != nil { 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"} } 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) { 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 %s package for x86-64", release.TagName, mode) } 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 { 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 := launcherLatestRelease(effectiveReleaseRepo()) if err != nil { return err } if latestNow := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v"); latestNow != st.Latest { return fmt.Errorf("Release änderte sich während der Update-Prüfung (%s → %s); Update wird beim nächsten Lauf erneut geprüft", st.Latest, latestNow) } 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 SHA-256 digest/checksum; refusing automatic update") } if st.Mode == "deb" || st.Mode == "rpm" || st.Mode == "arch" { if !system || os.Geteuid() != 0 { if commandExists("pkexec") { self := a.stableExecutable() 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 fmt.Errorf("%s package update needs root privileges and pkexec is unavailable", st.Mode) } return a.applyNativePackageUpdate(st.Mode, asset, st.Latest, quiet) } return a.applyUserUpdate(asset, st.Latest, quiet) } 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 } 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 } 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 } 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 { cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr } else { 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("%s package update failed: %w", mode, 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) } } 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") } 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)) } lines := strings.Split(strings.TrimSpace(string(out)), "\n") if len(lines) < 3 { return fmt.Errorf("unexpected RPM package metadata: %q", strings.TrimSpace(string(out))) } 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") } 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 } 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 SHA-256 digest/checksum; 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) } }