Citizen Launcher
Linux wird erkannt …
SYSTEM CHECK
Hardware und Gaming-Stack werden geprüft.
Gaming Stack
Autopilot
Citizen Launcher
Linux wird erkannt …
SYSTEM CHECK
Hardware und Gaming-Stack werden geprüft.
diff --git a/README.md b/README.md index 4f21cfa..9d83820 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Citizen Launcher 0.9.0 +# Citizen Launcher 0.9.1 Citizen Launcher is the distro-neutral successor to Omarchy Citizen. @@ -79,3 +79,12 @@ For Omarchy plus bar integration: ## Security boundaries 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 + +- 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. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f789850..a6212a1 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,36 +1,9 @@ -# Citizen Launcher 0.9.0 — distro-neutral preview +# Citizen Launcher 0.9.1 -This release turns Omarchy Citizen into an optional integration instead of the product core. +Maintenance/UX hotfix for the distro-neutral preview. -## New - -- standalone distro-neutral `citizen-launcher` Go binary -- embedded polished local GUI (`citizen-launcher gui`) -- Debian/Ubuntu `.deb` packaging -- generic Linux amd64 tarball -- Fedora RPM spec template -- distro detection via `/etc/os-release` -- package-manager detection for apt/dnf/pacman/zypper/apk diagnostics -- XDG-native config/data/state/cache paths with legacy Omarchy-Citizen migration -- generic systemd user maintenance timer, with launch-time maintenance fallback on non-systemd desktops -- support bundles generated entirely in Go -- Omarchy moved to `integrations/omarchy/` -- system tools preferred; LUG AppImage runtime is only a lazy portability fallback when cabextract/curl/unzip are unavailable - -## Validation performed - -- `go test ./...` -- `go vet ./...` -- static Linux amd64 Go build -- shell syntax verification -- embedded GUI HTTP/API smoke test -- generic user installer smoke test in an isolated HOME -- Omarchy updater integration test -- Debian package build and metadata inspection - -## Still needs real-hardware testing - -- clean Debian/Ubuntu desktop → RSI login/install → Star Citizen launch -- Fedora/openSUSE hardware testing -- AMD/NVIDIA/Intel Vulkan permutations -- production standalone self-update release channel once an official repository/release URL is chosen +- Removed mandatory PowerShell Core from base Winetricks setup. +- Robust RSI Electron Builder `latest.yml` parsing (`path`, nested `url`, version fallback). +- 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. diff --git a/backend/bin/citizen-launcher b/backend/bin/citizen-launcher index 3ca1c3c..5cecb9b 100644 Binary files a/backend/bin/citizen-launcher and b/backend/bin/citizen-launcher differ diff --git a/backend/cmd/citizen-launcher/gui.go b/backend/cmd/citizen-launcher/gui.go index 7567430..7563b07 100644 --- a/backend/cmd/citizen-launcher/gui.go +++ b/backend/cmd/citizen-launcher/gui.go @@ -42,6 +42,30 @@ type jobStore struct { jobs map[string]*GUIJob } +func friendlyActionError(action string, err error) string { + if err == nil { + return "" + } + s := err.Error() + switch { + case strings.Contains(s, "Windows-Komponente"): + return "Eine Windows-Komponente konnte nicht eingerichtet werden. Der technische Fehler ist unten verfügbar." + case strings.Contains(s, "RSI latest.yml") || strings.Contains(s, "RSI Installer"): + return "Die aktuelle RSI-Launcher-Version konnte nicht zuverlässig ermittelt werden." + case strings.Contains(s, "RSI Launcher Installation"): + return "Der RSI Launcher konnte nicht installiert oder aktualisiert werden." + case strings.Contains(strings.ToLower(s), "vulkan"): + return "Vulkan ist auf diesem System nicht spielbereit." + case strings.Contains(strings.ToLower(s), "wine"): + return "Der Wine-Stack konnte den Selbsttest oder die Reparatur nicht abschließen." + default: + if action == "repair" { + return "Die automatische Reparatur konnte nicht vollständig abgeschlossen werden." + } + return "Die Aktion konnte nicht vollständig abgeschlossen werden." + } +} + func (a *App) runGUI(args []string) error { noOpen := hasArg(args, "--no-open") listener, err := net.Listen("tcp", "127.0.0.1:0") @@ -143,6 +167,7 @@ func (a *App) runGUI(args []string) error { j.Finished = time.Now().Format(time.RFC3339) if err != nil { j.State = "error" + j.Message = friendlyActionError(action, err) j.Error = err.Error() } else { j.State = "done" diff --git a/backend/cmd/citizen-launcher/main.go b/backend/cmd/citizen-launcher/main.go index 75e4219..204a1de 100644 --- a/backend/cmd/citizen-launcher/main.go +++ b/backend/cmd/citizen-launcher/main.go @@ -16,7 +16,7 @@ import ( ) const ( - appVersion = "0.9.0" + appVersion = "0.9.1" pluginID = "local.omarchy-citizen" appID = "io.github.citizenlauncher.CitizenLauncher" ) diff --git a/backend/cmd/citizen-launcher/stack.go b/backend/cmd/citizen-launcher/stack.go index e7fba57..b260e63 100644 --- a/backend/cmd/citizen-launcher/stack.go +++ b/backend/cmd/citizen-launcher/stack.go @@ -27,6 +27,12 @@ const ( rsiLatestYML = rsiBaseURL + "/latest.yml" ) +// Keep the mandatory prefix small and boring. PowerShell is deliberately not +// a base dependency: current LUG documentation exposes it as a separate +// maintenance/troubleshooting action, and PowerShell Core MSI installation can +// fail under otherwise healthy Wine prefixes. +var basePrefixWinetricksVerbs = []string{"arial", "tahoma", "win11"} + type GameConfig struct { Prefix string `json:"prefix"` GameDir string `json:"game_dir"` @@ -427,6 +433,78 @@ func (a *App) syncWinetricks() (string, string, error) { return target, tag, nil } +func parseRSILatestYML(b []byte) (string, string, error) { + var version, topPath, topSHA, firstURL, firstURLSHA string + urlIndent := -1 + scanner := bufio.NewScanner(strings.NewReader(string(b))) + for scanner.Scan() { + raw := strings.TrimRight(scanner.Text(), "\r\n") + trimmed := strings.TrimSpace(raw) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + indent := len(raw) - len(strings.TrimLeft(raw, " \t")) + norm := strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")) + key, value, ok := strings.Cut(norm, ":") + if !ok { + continue + } + key = strings.TrimSpace(key) + value = yamlScalar(value) + switch key { + case "version": + if version == "" { + version = value + } + case "path": + if indent == 0 && topPath == "" { + topPath = value + } + case "url": + if firstURL == "" { + firstURL = value + urlIndent = indent + } + case "sha512": + if indent == 0 && topSHA == "" { + topSHA = value + } else if firstURL != "" && firstURLSHA == "" && indent > urlIndent { + firstURLSHA = value + } + } + } + if err := scanner.Err(); err != nil { + return "", "", err + } + file := topPath + sha := topSHA + if file == "" { + file = firstURL + if sha == "" { + sha = firstURLSHA + } + } + // Electron-builder normally supplies path/url, but the version fallback + // keeps us resilient if CIG trims redundant fields from latest.yml. + if file == "" && version != "" { + file = "RSI Launcher-Setup-" + version + ".exe" + } + if file == "" { + return "", "", errors.New("RSI latest.yml enthält weder path, url noch version") + } + return file, sha, nil +} + +func yamlScalar(v string) string { + v = strings.TrimSpace(v) + if len(v) >= 2 { + if (v[0] == '\'' && v[len(v)-1] == '\'') || (v[0] == '"' && v[len(v)-1] == '"') { + v = v[1 : len(v)-1] + } + } + return strings.TrimSpace(v) +} + func (a *App) latestRSIInstaller() (string, string, string, error) { req, _ := http.NewRequest("GET", rsiLatestYML, nil) req.Header.Set("User-Agent", "citizen-launcher/"+appVersion) @@ -443,22 +521,19 @@ func (a *App) latestRSIInstaller() (string, string, string, error) { if err != nil { return "", "", "", err } - reURL := regexp.MustCompile(`(?m)^url:\s*(.+?)\s*$`) - m := reURL.FindSubmatch(b) - if len(m) < 2 { - return "", "", "", errors.New("RSI latest.yml Format unbekannt") + file, sha, err := parseRSILatestYML(b) + if err != nil { + return "", "", "", err } - file := strings.TrimSpace(string(m[1])) - file = strings.Trim(file, "\"'") - sha := "" - reSHA := regexp.MustCompile(`(?m)^sha512:\s*(.+?)\s*$`) - if sm := reSHA.FindSubmatch(b); len(sm) >= 2 { - sha = strings.TrimSpace(string(sm[1])) - sha = strings.Trim(sha, "\"'") + ref, err := neturl.Parse(file) + if err != nil { + return "", "", "", fmt.Errorf("RSI Installer-Pfad ungültig: %w", err) + } + if ref.IsAbs() { + return filepath.Base(ref.Path), ref.String(), sha, nil } base, _ := neturl.Parse(rsiBaseURL + "/") - ref := &neturl.URL{Path: file} - return file, base.ResolveReference(ref).String(), sha, nil + return filepath.Base(ref.Path), base.ResolveReference(ref).String(), sha, nil } func verifySHA512Base64(path, want string) error { @@ -609,18 +684,22 @@ func (a *App) ensurePrefixComponents(gc GameConfig) error { cache := filepath.Join(a.cacheDir, "winetricks-cache", tag) _ = os.MkdirAll(cache, 0o755) env = append(env, "W_CACHE="+cache, "WINETRICKS_DOWNLOADER=curl") - cmd := exec.Command(wt, "-q", "arial", "tahoma", "powershell", "win11") - cmd.Env = env - out, err := cmd.CombinedOutput() - a.logf("winetricks %s output=%s", tag, compactDiagnostic(string(out))) - if err != nil { - return fmt.Errorf("Prefix-Komponenten: %s", formatCommandFailure(err, out)) + + for _, verb := range basePrefixWinetricksVerbs { + cmd := exec.Command(wt, "-q", verb) + cmd.Env = env + out, runErr := cmd.CombinedOutput() + a.logf("winetricks %s verb=%s output=%s", tag, verb, compactDiagnostic(string(out))) + if runErr != nil { + return fmt.Errorf("Windows-Komponente %s: %s", verb, formatCommandFailure(runErr, out)) + } } + 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`) + reg := exec.Command(wine, "reg", "add", `HKEY_CURRENT_USER\\Software\\Wine\\FileOpenAssociations`, `/v`, `Enable`, `/d`, `N`, `/f`) reg.Env = env - out, err = reg.CombinedOutput() + out, err := reg.CombinedOutput() if err != nil { return fmt.Errorf("Wine Registry: %s", formatCommandFailure(err, out)) } @@ -788,9 +867,6 @@ func (a *App) gameRepair() error { if hs.State == "blocked" { return errors.New(hs.Reason) } - if err := a.syncLUGHelper(); err != nil { - a.logf("LUG runtime warning: %v", err) - } if err := a.syncWineRunner(); err != nil { return err } @@ -803,6 +879,9 @@ func (a *App) gameRepair() error { if err := a.ensureRSILauncher(gc, false); err != nil { return err } + if err := a.syncDXVK(); err != nil { + return fmt.Errorf("DXVK: %w", err) + } if err := a.writeOwnedLaunchFiles(gc); err != nil { return err } diff --git a/backend/cmd/citizen-launcher/stack_test.go b/backend/cmd/citizen-launcher/stack_test.go new file mode 100644 index 0000000..b5a8030 --- /dev/null +++ b/backend/cmd/citizen-launcher/stack_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "strings" + "testing" +) + +func TestParseRSILatestElectronBuilder(t *testing.T) { + y := `version: 2.15.0 +files: + - url: RSI Launcher-Setup-2.15.0.exe + sha512: nested-sha + size: 123 +path: RSI Launcher-Setup-2.15.0.exe +sha512: top-sha +releaseDate: '2026-07-27T18:00:00.000Z' +` + file, sha, err := parseRSILatestYML([]byte(y)) + if err != nil { + t.Fatal(err) + } + if file != "RSI Launcher-Setup-2.15.0.exe" || sha != "top-sha" { + t.Fatalf("unexpected: file=%q sha=%q", file, sha) + } +} + +func TestParseRSILatestNestedOnly(t *testing.T) { + y := `version: 2.15.0 +files: + - url: "RSI Launcher-Setup-2.15.0.exe" + sha512: "nested-sha" +` + file, sha, err := parseRSILatestYML([]byte(y)) + if err != nil { + t.Fatal(err) + } + if file != "RSI Launcher-Setup-2.15.0.exe" || sha != "nested-sha" { + t.Fatalf("unexpected: file=%q sha=%q", file, sha) + } +} + +func TestParseRSILatestVersionFallback(t *testing.T) { + file, _, err := parseRSILatestYML([]byte("version: 2.15.0\n")) + if err != nil { + t.Fatal(err) + } + if file != "RSI Launcher-Setup-2.15.0.exe" { + t.Fatalf("unexpected file %q", file) + } +} + +func TestBasePrefixDoesNotRequirePowerShell(t *testing.T) { + for _, v := range basePrefixWinetricksVerbs { + if strings.EqualFold(v, "powershell") { + t.Fatalf("PowerShell must remain optional") + } + } +} diff --git a/backend/cmd/citizen-launcher/web/app.js b/backend/cmd/citizen-launcher/web/app.js index 036a012..f9a28b2 100644 --- a/backend/cmd/citizen-launcher/web/app.js +++ b/backend/cmd/citizen-launcher/web/app.js @@ -1 +1,11 @@ -const B=window.CITIZEN_BASE;let current=null;const $=id=>document.getElementById(id);async function api(path,opts){const r=await fetch(B+path,opts);if(!r.ok)throw new Error(await r.text());return r.json()}function cls(el,state){el.classList.remove('good','bad','warn');if(state)el.classList.add(state)}function txt(id,v,c){const e=$(id);e.textContent=v||'—';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']}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;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.'}$('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 action(name){try{if(name==='launch'){await api('/api/action/launch',{method:'POST'});return}const j=await api('/api/action/'+name,{method:'POST'});$('job').classList.remove('hidden');$('jobTitle').textContent='Aktion: '+name;$('jobText').textContent='läuft …';poll(j.id)}catch(e){$('job').classList.remove('hidden');$('jobTitle').textContent='Fehler';$('jobText').textContent=e.message}}async function poll(id){try{const j=await api('/api/job/'+id);if(j.state==='running'){setTimeout(()=>poll(id),1000);return}$('jobText').textContent=j.state==='done'?(j.message||'Abgeschlossen'):j.error;$('job').querySelector('.spinner').classList.toggle('hidden',j.state!=='running');await refresh()}catch(e){$('jobText').textContent=e.message}}document.addEventListener('click',e=>{const a=e.target.dataset.action;if(a)action(a);const o=e.target.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 +const B=window.CITIZEN_BASE;let current=null;const $=id=>document.getElementById(id); +async function api(path,opts){const r=await fetch(B+path,opts);if(!r.ok)throw new Error(await r.text());return r.json()} +function cls(el,state){el.classList.remove('good','bad','warn');if(state)el.classList.add(state)} +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}} +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 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 732be6c..68c6d8d 100644 --- a/backend/cmd/citizen-launcher/web/index.html +++ b/backend/cmd/citizen-launcher/web/index.html @@ -2,15 +2,15 @@
Linux wird erkannt …
Hardware und Gaming-Stack werden geprüft.
Linux wird erkannt …
Hardware und Gaming-Stack werden geprüft.