This commit is contained in:
2026-08-31 22:20:17 +02:00
parent eefada36e9
commit eeac39e7b1
40 changed files with 3203 additions and 449 deletions
Binary file not shown.
@@ -0,0 +1,293 @@
package main
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
const (
maxArchiveFileSize = int64(4 << 30) // 4 GiB per file is far above Wine/DXVK needs.
maxArchiveTotal = int64(12 << 30)
)
type deferredTarLink struct {
name string
linkname string
hard bool
}
// extractTarArchiveSafe extracts trusted release archives without letting archive
// paths, hardlinks or symlinks escape the staging directory. Compression is
// decoded separately; tar entries themselves are always processed in Go.
func extractTarArchiveSafe(path, dir string) error {
lower := strings.ToLower(path)
f, err := os.Open(path)
if err != nil {
return err
}
var reader io.Reader = f
var closeFns []func() error
closeFns = append(closeFns, f.Close)
switch {
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
gz, err := gzip.NewReader(f)
if err != nil {
_ = f.Close()
return err
}
reader = gz
closeFns = append([]func() error{gz.Close}, closeFns...)
case strings.HasSuffix(lower, ".tar.xz"):
_ = f.Close()
if !commandExists("xz") {
return errors.New("xz wird zum Entpacken dieses Wine-Archivs benötigt")
}
cmd := exec.Command("xz", "-dc", "--", path)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return err
}
extractErr := extractTarReaderSafe(stdout, dir)
if extractErr != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
waitErr := cmd.Wait()
if extractErr != nil {
return extractErr
}
if waitErr != nil {
return fmt.Errorf("xz konnte Archiv nicht dekomprimieren: %s", compactDiagnostic(stderr.String()))
}
return nil
case strings.HasSuffix(lower, ".tar.zst"), strings.HasSuffix(lower, ".tar.zstd"):
_ = f.Close()
if !commandExists("zstd") {
return errors.New("zstd wird zum Entpacken dieses Wine-Archivs benötigt")
}
cmd := exec.Command("zstd", "-q", "-dc", "--", path)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return err
}
extractErr := extractTarReaderSafe(stdout, dir)
if extractErr != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
waitErr := cmd.Wait()
if extractErr != nil {
return extractErr
}
if waitErr != nil {
return fmt.Errorf("zstd konnte Archiv nicht dekomprimieren: %s", compactDiagnostic(stderr.String()))
}
return nil
case strings.HasSuffix(lower, ".tar"):
// plain tar
default:
for _, closeFn := range closeFns {
_ = closeFn()
}
return fmt.Errorf("nicht unterstütztes Archivformat: %s", filepath.Base(path))
}
err = extractTarReaderSafe(reader, dir)
for _, closeFn := range closeFns {
if closeErr := closeFn(); err == nil && closeErr != nil {
err = closeErr
}
}
return err
}
func extractTarReaderSafe(r io.Reader, dir string) error {
root, err := filepath.Abs(dir)
if err != nil {
return err
}
if err := os.MkdirAll(root, 0o755); err != nil {
return err
}
tr := tar.NewReader(r)
var links []deferredTarLink
var total int64
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
name, target, err := safeArchiveTarget(root, h.Name)
if err != nil {
return err
}
if name == "." {
continue
}
switch h.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg, tar.TypeRegA:
if h.Size < 0 || h.Size > maxArchiveFileSize {
return fmt.Errorf("Archivdatei zu groß: %q", h.Name)
}
total += h.Size
if total > maxArchiveTotal {
return errors.New("Archiv überschreitet das sichere Größenlimit")
}
if err := ensureNoSymlinkParents(root, target); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
mode := os.FileMode(h.Mode) & 0o755
if mode == 0 {
mode = 0o644
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
_, copyErr := io.CopyN(out, tr, h.Size)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
case tar.TypeSymlink:
links = append(links, deferredTarLink{name: name, linkname: h.Linkname})
case tar.TypeLink:
links = append(links, deferredTarLink{name: name, linkname: h.Linkname, hard: true})
default:
return fmt.Errorf("nicht unterstützter/unsicherer Tar-Eintrag %q (Typ %d)", h.Name, h.Typeflag)
}
}
// Hardlinks first, then symlinks. Deferring links prevents a later regular
// archive member from writing through a just-created symlink parent.
for _, link := range links {
if !link.hard {
continue
}
_, dst, err := safeArchiveTarget(root, link.name)
if err != nil {
return err
}
_, src, err := safeArchiveTarget(root, link.linkname)
if err != nil {
return fmt.Errorf("unsicherer Hardlink %q -> %q: %w", link.name, link.linkname, err)
}
if err := ensureNoSymlinkParents(root, dst); err != nil {
return err
}
if fi, err := os.Stat(src); err != nil || !fi.Mode().IsRegular() {
return fmt.Errorf("Hardlink-Ziel ist keine reguläre Datei: %q", link.linkname)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
_ = os.Remove(dst)
if err := os.Link(src, dst); err != nil {
return err
}
}
for _, link := range links {
if link.hard {
continue
}
_, dst, err := safeArchiveTarget(root, link.name)
if err != nil {
return err
}
if filepath.IsAbs(link.linkname) {
return fmt.Errorf("absoluter Symlink im Archiv: %q -> %q", link.name, link.linkname)
}
resolved := filepath.Clean(filepath.Join(filepath.Dir(dst), filepath.FromSlash(link.linkname)))
if !pathInside(root, resolved) {
return fmt.Errorf("Symlink verlässt Zielverzeichnis: %q -> %q", link.name, link.linkname)
}
if err := ensureNoSymlinkParents(root, dst); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
_ = os.Remove(dst)
if err := os.Symlink(filepath.FromSlash(link.linkname), dst); err != nil {
return err
}
}
return nil
}
func safeArchiveTarget(root, archiveName string) (string, string, error) {
name := filepath.Clean(filepath.FromSlash(strings.TrimSpace(archiveName)))
if name == "" || name == "." {
return ".", root, nil
}
if filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, ".."+string(os.PathSeparator)) {
return "", "", fmt.Errorf("unsicherer Archivpfad: %q", archiveName)
}
target := filepath.Join(root, name)
if !pathInside(root, target) {
return "", "", fmt.Errorf("Archivpfad verlässt Zielverzeichnis: %q", archiveName)
}
return name, target, nil
}
func pathInside(root, target string) bool {
rel, err := filepath.Rel(root, target)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}
func ensureNoSymlinkParents(root, target string) error {
rel, err := filepath.Rel(root, filepath.Dir(target))
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return errors.New("Zielpfad liegt außerhalb des Entpackverzeichnisses")
}
cur := root
if rel == "." {
return nil
}
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
cur = filepath.Join(cur, part)
fi, err := os.Lstat(cur)
if os.IsNotExist(err) {
continue
}
if err != nil {
return err
}
if fi.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("Symlink als Archiv-Zwischenpfad wird abgelehnt: %s", cur)
}
}
return nil
}
@@ -0,0 +1,86 @@
package main
import (
"archive/tar"
"compress/gzip"
"os"
"path/filepath"
"testing"
)
type tarTestEntry struct {
name string
link string
typ byte
data []byte
mode int64
}
func makeTarGz(t *testing.T, entries []tarTestEntry) string {
t.Helper()
path := filepath.Join(t.TempDir(), "fixture.tar.gz")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
for _, e := range entries {
mode := e.mode
if mode == 0 {
mode = 0o644
}
h := &tar.Header{Name: e.name, Linkname: e.link, Typeflag: e.typ, Mode: mode, Size: int64(len(e.data))}
if e.typ == tar.TypeSymlink || e.typ == tar.TypeLink || e.typ == tar.TypeDir {
h.Size = 0
}
if err := tw.WriteHeader(h); err != nil {
t.Fatal(err)
}
if len(e.data) != 0 {
if _, err := tw.Write(e.data); err != nil {
t.Fatal(err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
return path
}
func TestExtractTarArchiveSafeRegularAndSymlink(t *testing.T) {
archive := makeTarGz(t, []tarTestEntry{
{name: "runner/bin/wine", typ: tar.TypeReg, data: []byte("wine"), mode: 0o755},
{name: "runner/bin/wine64", typ: tar.TypeSymlink, link: "wine"},
})
dst := t.TempDir()
if err := extractTarArchiveSafe(archive, dst); err != nil {
t.Fatal(err)
}
if b, err := os.ReadFile(filepath.Join(dst, "runner", "bin", "wine")); err != nil || string(b) != "wine" {
t.Fatalf("regular file missing/corrupt: %q %v", b, err)
}
link, err := os.Readlink(filepath.Join(dst, "runner", "bin", "wine64"))
if err != nil || link != "wine" {
t.Fatalf("safe symlink missing: %q %v", link, err)
}
}
func TestExtractTarArchiveSafeRejectsTraversal(t *testing.T) {
for _, entries := range [][]tarTestEntry{
{{name: "../escape", typ: tar.TypeReg, data: []byte("x")}},
{{name: "runner/link", typ: tar.TypeSymlink, link: "../../escape"}},
{{name: "/absolute", typ: tar.TypeReg, data: []byte("x")}},
} {
if err := extractTarArchiveSafe(makeTarGz(t, entries), t.TempDir()); err == nil {
t.Fatalf("unsafe tar accepted: %#v", entries)
}
}
}
+318 -67
View File
@@ -34,10 +34,11 @@ type githubAsset struct {
}
type componentMeta struct {
Version string `json:"version"`
Asset string `json:"asset,omitempty"`
URL string `json:"url,omitempty"`
Updated string `json:"updated"`
Version string `json:"version"`
Asset string `json:"asset,omitempty"`
URL string `json:"url,omitempty"`
Updated string `json:"updated"`
Fingerprint string `json:"fingerprint,omitempty"`
}
type wineRejection struct {
@@ -47,6 +48,25 @@ type wineRejection struct {
Updated string `json:"updated"`
}
func (a *App) syncMaintenanceTimer() error {
cfg := a.loadConfig()
want := cfg.AutoMaintain || cfg.AutoApply
if !detectPlatform().SystemdUser {
if want {
return a.selfSync()
}
return nil
}
if want {
if err := a.installService(); err != nil {
return err
}
return run("", "systemctl", "--user", "enable", "--now", "citizen-launcher-maintenance.timer")
}
_ = run("", "systemctl", "--user", "disable", "--now", "citizen-launcher-maintenance.timer")
return nil
}
func (a *App) enableAutopilot() error {
cfg := a.loadConfig()
cfg.AutoMaintain = true
@@ -61,21 +81,8 @@ func (a *App) enableAutopilot() error {
}
cfg.LastResult = "autopilot-enabled"
a.saveConfig(cfg)
// systemd is common on Debian/Fedora/Arch/openSUSE, but not required by the
// launcher. Non-systemd desktops are maintained opportunistically on launch.
if detectPlatform().SystemdUser {
if err := a.installService(); err != nil {
return err
}
if err := run("", "systemctl", "--user", "enable", "--now", "citizen-launcher-maintenance.timer"); err != nil {
return err
}
} else {
if err := a.selfSync(); err != nil {
return err
}
a.logf("autopilot enabled without systemd user manager; launch-time maintenance active")
if err := a.syncMaintenanceTimer(); err != nil {
return err
}
a.logf("autopilot enabled auto_integration=%v", cfg.AutoApply)
fmt.Println("enabled")
@@ -87,6 +94,9 @@ func (a *App) disableAutopilot() error {
cfg.AutoMaintain = false
cfg.LastResult = "autopilot-disabled"
a.saveConfig(cfg)
if err := a.syncMaintenanceTimer(); err != nil {
return err
}
a.logf("autopilot disabled")
fmt.Println("disabled")
return nil
@@ -99,13 +109,32 @@ func (a *App) maintainGamingStack() error {
}
defer unlock()
gc := a.loadGameConfig()
if prefixInitialized(gc.Prefix) && prefixBusy(gc.Prefix) {
a.logf("maintenance deferred: Wine prefix is active")
return nil
}
a.logf("maintenance begin")
var problems []string
// LUG is no longer synchronized during normal maintenance. It is only a
// lazy portable-toolbox fallback when distro tools are missing.
wineReady := true
if err := a.syncWineRunner(); err != nil {
problems = append(problems, "Wine runner: "+err.Error())
wineReady = false
}
// Keep the RSI PowerShell compatibility layer healthy without invoking
// Winetricks/MSI during background maintenance. The prefix-busy guard above
// ensures that no live RSI/game process is modified underneath the user.
if wineReady && prefixInitialized(gc.Prefix) && a.powerShellState(gc.Prefix) != "ready" {
env, _, envErr := a.runnerEnv(gc.Prefix)
if envErr != nil {
problems = append(problems, "RSI compatibility: "+envErr.Error())
} else if psErr := a.ensurePowerShell(gc, env); psErr != nil {
problems = append(problems, "RSI compatibility: "+psErr.Error())
}
}
if err := a.syncDXVK(); err != nil {
problems = append(problems, "DXVK: "+err.Error())
@@ -122,6 +151,7 @@ func (a *App) maintainGamingStack() error {
c.LastMaintenance = time.Now().Format(time.RFC3339)
c.MaintenanceResult = result
})
a.cleanupManagedState()
a.logf("maintenance end result=%s", result)
if len(problems) > 0 {
@@ -159,7 +189,7 @@ func (a *App) syncLUGHelper() error {
if err := download(asset.BrowserDownloadURL, tmp); err != nil {
return err
}
if err := verifyReleaseDigest(tmp, asset.Digest); err != nil {
if err := requireReleaseDigest(tmp, asset.Digest); err != nil {
_ = os.Remove(tmp)
return err
}
@@ -204,17 +234,7 @@ func (a *App) syncWineRunner() error {
var failures []string
for _, release := range releases {
asset, assetErr := selectAsset(release.Assets, func(n string) bool {
n = strings.ToLower(n)
if strings.Contains(n, "staging") || strings.Contains(n, "checksum") ||
strings.Contains(n, "sha") || strings.Contains(n, "experimental") ||
strings.Contains(n, "wayland") {
return false
}
return strings.Contains(n, "lug-wine-tkg-git") &&
(strings.HasSuffix(n, ".tar.xz") || strings.HasSuffix(n, ".tar.zst") ||
strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz"))
})
asset, assetErr := selectWineAsset(release.Assets)
if assetErr != nil {
continue
}
@@ -226,12 +246,28 @@ func (a *App) syncWineRunner() error {
continue
}
// Already-active compatible version: no download/test needed.
if readMetaVersion(metaPath) == release.TagName {
// Re-test an already-active runner when the CPU/libc fingerprint changed.
// This catches an OS upgrade that invalidates a previously working runner.
if meta := readComponentMeta(metaPath); meta.Version == release.TagName {
if p, _ := filepath.EvalSymlinks(current); p != "" {
if _, err := os.Stat(filepath.Join(p, "bin", "wine")); err == nil {
a.activateRunnerForPrefix(p)
return nil
if meta.Fingerprint == fingerprint {
a.activateRunnerForPrefix(p)
return nil
}
diag, testErr := a.selfTestRunnerDetailed(p)
if testErr == nil {
meta.Fingerprint = fingerprint
meta.Updated = time.Now().Format(time.RFC3339)
_ = writeMeta(metaPath, meta)
a.logf("active Wine runner revalidated: %s diagnostic=%s", release.TagName, compactDiagnostic(diag))
a.activateRunnerForPrefix(p)
return nil
}
reason := testErr.Error()
rejections[release.TagName] = wineRejection{Version: release.TagName, Reason: reason, Fingerprint: fingerprint, Updated: time.Now().Format(time.RFC3339)}
_ = writeWineRejections(rejectPath, rejections)
failures = append(failures, release.TagName+" no longer compatible: "+reason)
}
}
}
@@ -241,7 +277,7 @@ func (a *App) syncWineRunner() error {
failures = append(failures, release.TagName+" download: "+err.Error())
continue
}
if err := verifyReleaseDigest(archive, asset.Digest); err != nil {
if err := requireReleaseDigest(archive, asset.Digest); err != nil {
_ = os.Remove(archive)
failures = append(failures, release.TagName+" checksum: "+err.Error())
continue
@@ -252,11 +288,9 @@ func (a *App) syncWineRunner() error {
return err
}
extract := exec.Command("tar", "-xf", archive, "-C", stage)
out, extractErr := extract.CombinedOutput()
if extractErr != nil {
if extractErr := extractTarArchiveSafe(archive, stage); extractErr != nil {
os.RemoveAll(stage)
failures = append(failures, release.TagName+" extract: "+formatCommandFailure(extractErr, out))
failures = append(failures, release.TagName+" extract: "+extractErr.Error())
continue
}
@@ -294,6 +328,16 @@ func (a *App) syncWineRunner() error {
return err
}
// Preserve one known-previous runner for diagnostics/manual rollback.
if oldCurrent, err := filepath.EvalSymlinks(current); err == nil && oldCurrent != "" && oldCurrent != target {
previous := filepath.Join(base, "previous")
tmpPrevious := previous + ".new"
_ = os.Remove(tmpPrevious)
if os.Symlink(oldCurrent, tmpPrevious) == nil {
_ = os.Rename(tmpPrevious, previous)
}
}
tmpLink := current + ".new"
_ = os.Remove(tmpLink)
if err := os.Symlink(target, tmpLink); err != nil {
@@ -307,7 +351,7 @@ func (a *App) syncWineRunner() error {
if err := writeMeta(metaPath, componentMeta{
Version: release.TagName, Asset: asset.Name, URL: asset.BrowserDownloadURL,
Updated: time.Now().Format(time.RFC3339),
Updated: time.Now().Format(time.RFC3339), Fingerprint: fingerprint,
}); err != nil {
os.RemoveAll(stage)
return err
@@ -322,6 +366,19 @@ func (a *App) syncWineRunner() error {
return nil
}
// A previously validated runner may have fallen out of the recent-release
// window. Never strand a working installation just because newer candidates
// are incompatible with this CPU/libc combination.
if p, err := filepath.EvalSymlinks(current); err == nil && p != "" {
if diag, testErr := a.selfTestRunnerDetailed(p); testErr == nil {
meta := readComponentMeta(metaPath)
meta.Fingerprint = fingerprint
meta.Updated = time.Now().Format(time.RFC3339)
_ = writeMeta(metaPath, meta)
a.logf("no newer compatible Wine runner; keeping %s diagnostic=%s", meta.Version, compactDiagnostic(diag))
return nil
}
}
if len(failures) == 0 {
return errors.New("no stable LUG Wine asset found in recent releases")
}
@@ -355,7 +412,8 @@ func (a *App) selfTestRunnerDetailed(runner string) (string, error) {
}
defer os.RemoveAll(tmp)
env := append(os.Environ(),
env := environmentWithout(os.Environ(), "SDL_VIDEODRIVER", "WINE", "WINESERVER", "WINEPREFIX", "WINEARCH", "WINEDEBUG", "WINEDLLOVERRIDES")
env = append(env,
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"WINE="+wine,
"WINEPREFIX="+tmp,
@@ -440,13 +498,19 @@ func (a *App) selfTestRunnerDetailed(runner string) (string, error) {
func formatCommandFailure(err error, out []byte) string {
text := strings.TrimSpace(string(out))
if len(text) > 1200 {
// Keep status output compact but preserve enough detail in updater.log.
text = text[len(text)-1200:]
}
if err == nil {
if text == "" {
return "command produced no expected result"
}
return text
}
if text == "" {
return err.Error()
}
// Keep status output compact but preserve enough detail in updater.log.
if len(text) > 1200 {
text = text[len(text)-1200:]
}
return err.Error() + ": " + text
}
@@ -459,6 +523,11 @@ func compactDiagnostic(s string) string {
}
func (a *App) wineDoctor() error {
lock, err := a.stackOperationLock(2 * time.Second)
if err != nil {
return err
}
defer releaseFileLock(lock)
current := filepath.Join(a.vendorDir, "wine", "current")
root, err := filepath.EvalSymlinks(current)
if err != nil {
@@ -504,14 +573,73 @@ func (a *App) activateRunnerForPrefix(runner string) {
}
}
var dxvkRequiredDLLs = []string{"d3d8.dll", "d3d9.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll"}
var dxvkOverrideNames = []string{"d3d8", "d3d9", "d3d10core", "d3d11", "dxgi"}
func (a *App) dxvkMarkerPath() string { return filepath.Join(a.vendorDir, "dxvk", "overrides.txt") }
func (a *App) dxvkDLLsReady(prefix string) bool {
if !prefixInitialized(prefix) {
return false
}
system32 := filepath.Join(prefix, "drive_c", "windows", "system32")
for _, name := range dxvkRequiredDLLs {
fi, err := os.Stat(filepath.Join(system32, name))
if err != nil || fi.Size() < 1024 {
return false
}
}
return true
}
func (a *App) dxvkState(prefix, version string) string {
if !prefixInitialized(prefix) || version == "" {
return "missing"
}
if !a.dxvkDLLsReady(prefix) {
return "repair"
}
marker := readText(a.dxvkMarkerPath())
want := version + "\n" + prefix
if marker != want {
return "repair"
}
return "ready"
}
func (a *App) ensureDXVKOverrides(prefix, version string) error {
if prefixBusy(prefix) {
return errors.New("DXVK kann nicht geändert werden, solange der Wine-Prefix verwendet wird")
}
env, runner, err := a.runnerEnv(prefix)
if err != nil {
return err
}
wine := filepath.Join(runner, "bin", "wine")
const key = `HKEY_CURRENT_USER\Software\Wine\DllOverrides`
for _, dll := range dxvkOverrideNames {
cmd := exec.Command(wine, "reg", "add", key, "/v", dll, "/t", "REG_SZ", "/d", "native", "/f")
cmd.Env = env
out, runErr := cmd.CombinedOutput()
if runErr != nil {
return fmt.Errorf("DXVK DLL-Override %s: %s", dll, formatCommandFailure(runErr, out))
}
}
wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w")
wait.Env = env
if out, runErr := wait.CombinedOutput(); runErr != nil {
return fmt.Errorf("DXVK Registry speichern: %s", formatCommandFailure(runErr, out))
}
return atomicWriteFile(a.dxvkMarkerPath(), []byte(version+"\n"+prefix+"\n"), 0o600)
}
func (a *App) syncDXVK() error {
prefix := a.configuredPrefix()
if prefix == "" || !prefixInitialized(prefix) {
return nil
}
if prefixBusy(prefix) {
a.logf("DXVK update deferred: Star Citizen Wine prefix is active")
return nil
return errors.New("DXVK-Aktualisierung pausiert: Wine-Prefix ist aktiv")
}
release, err := githubLatest(dxvkRepo)
@@ -528,7 +656,11 @@ func (a *App) syncDXVK() error {
base := filepath.Join(a.vendorDir, "dxvk")
metaPath := filepath.Join(base, "meta.json")
if readMetaVersion(metaPath) == release.TagName {
currentVersion := readMetaVersion(metaPath)
if currentVersion == release.TagName && a.dxvkDLLsReady(prefix) {
if err := a.ensureDXVKOverrides(prefix, release.TagName); err != nil {
return err
}
return nil
}
if err := os.MkdirAll(base, 0o755); err != nil {
@@ -541,7 +673,7 @@ func (a *App) syncDXVK() error {
if err := download(asset.BrowserDownloadURL, archive); err != nil {
return err
}
if err := verifyReleaseDigest(archive, asset.Digest); err != nil {
if err := requireReleaseDigest(archive, asset.Digest); err != nil {
_ = os.Remove(archive)
return err
}
@@ -550,8 +682,8 @@ func (a *App) syncDXVK() error {
return err
}
defer os.RemoveAll(stage)
if out, err := exec.Command("tar", "-xf", archive, "-C", stage).CombinedOutput(); err != nil {
return fmt.Errorf("extract DXVK: %s", strings.TrimSpace(string(out)))
if err := extractTarArchiveSafe(archive, stage); err != nil {
return fmt.Errorf("DXVK entpacken: %w", err)
}
root, err := findRootContaining(stage, filepath.Join("x64", "dxgi.dll"))
if err != nil {
@@ -571,13 +703,19 @@ func (a *App) syncDXVK() error {
return err
}
}
if !a.dxvkDLLsReady(prefix) {
return errors.New("DXVK-Installation unvollständig: benötigte x64-DLLs fehlen")
}
if err := a.ensureDXVKOverrides(prefix, release.TagName); err != nil {
return err
}
if err := writeMeta(metaPath, componentMeta{
Version: release.TagName, Asset: asset.Name, URL: asset.BrowserDownloadURL,
Updated: time.Now().Format(time.RFC3339),
}); err != nil {
return err
}
a.logf("DXVK updated to %s", release.TagName)
a.logf("DXVK updated to %s and native DLL overrides activated", release.TagName)
_ = os.Remove(archive)
return nil
}
@@ -628,7 +766,13 @@ func prefixBusy(prefix string) bool {
if err != nil {
return false
}
needle := []byte("WINEPREFIX=" + prefix)
// Wine keeps a few infrastructure processes around briefly even when no
// user-facing program is active. Those are safe to terminate/restart while
// holding the stack lock and must not permanently block maintenance.
infrastructure := []string{
"wineserver", "services.exe", "winedevice.exe", "explorer.exe",
"plugplay.exe", "rpcss.exe", "svchost.exe", "conhost.exe",
}
for _, e := range proc {
if !e.IsDir() {
continue
@@ -638,7 +782,19 @@ func prefixBusy(prefix string) bool {
continue
}
data, err := os.ReadFile(filepath.Join("/proc", name, "environ"))
if err == nil && strings.Contains(string(data), string(needle)) {
if err != nil || !processHasEnvValue(data, "WINEPREFIX", prefix) {
continue
}
cmdline, _ := os.ReadFile(filepath.Join("/proc", name, "cmdline"))
cmd := strings.ToLower(strings.ReplaceAll(string(cmdline), "\x00", " "))
ignored := false
for _, infra := range infrastructure {
if strings.Contains(cmd, infra) {
ignored = true
break
}
}
if !ignored {
return true
}
}
@@ -743,6 +899,31 @@ func githubLatest(repo string) (githubRelease, error) {
return rel, nil
}
func selectWineAsset(assets []githubAsset) (githubAsset, error) {
valid := func(n string) bool {
n = strings.ToLower(n)
if strings.Contains(n, "staging") || strings.Contains(n, "checksum") ||
strings.Contains(n, "sha") || strings.Contains(n, "experimental") ||
strings.Contains(n, "wayland") || !strings.Contains(n, "lug-wine-tkg-git") {
return false
}
return strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") ||
strings.HasSuffix(n, ".tar.xz") || strings.HasSuffix(n, ".tar.zst")
}
// Prefer gzip: it is decoded entirely in-process and therefore works on all
// supported distributions without another decompressor. XZ/Zstd remain
// compatible fallbacks for future upstream packaging changes.
for _, suffix := range []string{".tar.gz", ".tgz", ".tar.xz", ".tar.zst"} {
asset, err := selectAsset(assets, func(n string) bool {
return valid(n) && strings.HasSuffix(strings.ToLower(n), suffix)
})
if err == nil {
return asset, nil
}
}
return githubAsset{}, errors.New("no stable LUG Wine archive found")
}
func selectAsset(assets []githubAsset, match func(string) bool) (githubAsset, error) {
var candidates []githubAsset
for _, a := range assets {
@@ -855,38 +1036,98 @@ func copyTree(src, dst string) error {
})
}
func readMetaVersion(path string) string {
func readComponentMeta(path string) componentMeta {
data, err := os.ReadFile(path)
if err != nil {
return ""
return componentMeta{}
}
var meta componentMeta
if json.Unmarshal(data, &meta) != nil {
return ""
return componentMeta{}
}
return meta.Version
return meta
}
func readMetaVersion(path string) string { return readComponentMeta(path).Version }
func writeMeta(path string, meta componentMeta) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, _ := json.MarshalIndent(meta, "", " ")
data = append(data, '\n')
tmp := path + ".new"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
return atomicWriteFile(path, data, 0o644)
}
func (a *App) saveConfig(cfg Config) {
_ = os.MkdirAll(a.configDir, 0o755)
data, _ := json.MarshalIndent(cfg, "", " ")
data = append(data, '\n')
tmp := a.configPath + ".tmp"
if os.WriteFile(tmp, data, 0o600) == nil {
_ = os.Rename(tmp, a.configPath)
_ = atomicWriteFile(a.configPath, data, 0o600)
}
func (a *App) cleanupManagedState() {
// Keep the active and previous Wine runners; old extracted runners can be
// large and are safe to re-download if ever needed again.
wineBase := filepath.Join(a.vendorDir, "wine")
keep := map[string]bool{}
for _, link := range []string{"current", "previous"} {
if p, err := filepath.EvalSymlinks(filepath.Join(wineBase, link)); err == nil && p != "" {
keep[filepath.Clean(p)] = true
}
}
if entries, err := os.ReadDir(wineBase); err == nil {
for _, e := range entries {
if !e.IsDir() || strings.HasSuffix(e.Name(), ".new") {
continue
}
p := filepath.Join(wineBase, e.Name())
if !keep[filepath.Clean(p)] {
_ = os.RemoveAll(p)
}
}
}
pruneNamedDirectories(filepath.Join(a.vendorDir, "winetricks"), 2, "")
pruneNamedDirectories(a.stateDir, 3, "dxvk-backup-")
pruneOldCache(a.cacheDir, 30*24*time.Hour)
}
func pruneNamedDirectories(base string, keep int, prefix string) {
entries, err := os.ReadDir(base)
if err != nil {
return
}
type item struct {
path string
mod time.Time
}
var items []item
for _, e := range entries {
if !e.IsDir() || (prefix != "" && !strings.HasPrefix(e.Name(), prefix)) {
continue
}
if info, err := e.Info(); err == nil {
items = append(items, item{filepath.Join(base, e.Name()), info.ModTime()})
}
}
sort.Slice(items, func(i, j int) bool { return items[i].mod.After(items[j].mod) })
for i := keep; i < len(items); i++ {
_ = os.RemoveAll(items[i].path)
}
}
func pruneOldCache(base string, age time.Duration) {
entries, err := os.ReadDir(base)
if err != nil {
return
}
cutoff := time.Now().Add(-age)
for _, e := range entries {
info, err := e.Info()
if err == nil && info.ModTime().Before(cutoff) {
_ = os.RemoveAll(filepath.Join(base, e.Name()))
}
}
}
@@ -927,3 +1168,13 @@ func verifyReleaseDigest(path, digest string) error {
}
return nil
}
// Executable gaming-stack components fail closed when GitHub does not provide
// a SHA-256 digest. An upstream metadata outage is recoverable; activating an
// unverifiable Wine/DXVK/toolbox download is not.
func requireReleaseDigest(path, digest string) error {
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(digest)), "sha256:") {
return fmt.Errorf("GitHub asset %s has no SHA-256 digest; refusing unverified component", filepath.Base(path))
}
return verifyReleaseDigest(path, digest)
}
+231 -63
View File
@@ -15,6 +15,7 @@ import (
"path/filepath"
"strings"
"sync"
"syscall"
"time"
)
@@ -29,6 +30,7 @@ type GUIStatus struct {
Platform PlatformStatus `json:"platform"`
Game GameStatus `json:"game"`
Launcher Status `json:"launcher"`
ActiveJob *GUIJob `json:"active_job,omitempty"`
}
type GUIJob struct {
@@ -42,8 +44,107 @@ type GUIJob struct {
}
type jobStore struct {
mu sync.Mutex
jobs map[string]*GUIJob
mu sync.Mutex
jobs map[string]*GUIJob
activeID string
}
func (s *jobStore) active() *GUIJob {
s.mu.Lock()
defer s.mu.Unlock()
if s.activeID == "" {
return nil
}
j := s.jobs[s.activeID]
if j == nil || j.State != "running" {
s.activeID = ""
return nil
}
copy := *j
return &copy
}
func (s *jobStore) start(action string) (*GUIJob, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.activeID != "" {
if j := s.jobs[s.activeID]; j != nil && j.State == "running" {
return nil, fmt.Errorf("%s läuft bereits", friendlyActionName(j.Action))
}
s.activeID = ""
}
idBytes := make([]byte, 8)
if _, err := rand.Read(idBytes); err != nil {
return nil, fmt.Errorf("sichere Job-ID konnte nicht erzeugt werden: %w", err)
}
j := &GUIJob{ID: hex.EncodeToString(idBytes), Action: action, State: "running", Started: time.Now().Format(time.RFC3339)}
s.jobs[j.ID] = j
s.activeID = j.ID
return j, nil
}
func (s *jobStore) finish(id string, err error, msg string) {
s.mu.Lock()
defer s.mu.Unlock()
j := s.jobs[id]
if j == nil {
return
}
j.Finished = time.Now().Format(time.RFC3339)
if err != nil {
j.State = "error"
j.Message = friendlyActionError(j.Action, err)
j.Error = err.Error()
} else {
j.State = "done"
j.Message = msg
}
if s.activeID == id {
s.activeID = ""
}
// Bound memory for a GUI process that may stay open for days.
if len(s.jobs) > 60 {
for key, old := range s.jobs {
if old.State != "running" && key != id {
delete(s.jobs, key)
}
if len(s.jobs) <= 30 {
break
}
}
}
}
func (s *jobStore) get(id string) *GUIJob {
s.mu.Lock()
defer s.mu.Unlock()
j := s.jobs[id]
if j == nil {
return nil
}
copy := *j
return &copy
}
func friendlyActionName(action string) string {
switch action {
case "setup":
return "Einrichtung"
case "repair":
return "Reparatur"
case "maintain":
return "Gaming-Stack-Wartung"
case "doctor":
return "Wine-Selbsttest"
case "support":
return "Support-Paket"
case "prepare-system":
return "Systemvorbereitung"
case "choose-prefix":
return "Ordnerauswahl"
default:
return "Eine Aktion"
}
}
func friendlyActionError(action string, err error) string {
@@ -52,14 +153,20 @@ func friendlyActionError(action string, err error) string {
}
s := err.Error()
switch {
case errors.Is(err, ErrLauncherAlreadyRunning):
return "Der RSI Launcher läuft bereits."
case strings.Contains(s, "PowerShell-Kompatibilität") || strings.Contains(s, "PowerShell Selbsttest"):
return "Die für den RSI Launcher benötigte Windows-Kompatibilität konnte nicht vollständig eingerichtet werden. Citizen Launcher hat keine Spieldaten verändert."
case strings.Contains(s, "Windows-Komponente"):
return "Eine Windows-Komponente konnte nicht eingerichtet werden. Der technische Fehler ist unten verfügbar."
return "Eine Windows-Komponente konnte nicht eingerichtet werden. Die technischen Details sind 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), "nofile") || strings.Contains(strings.ToLower(s), "max_map_count"):
return "Eine notwendige Linux-Systemeinstellung konnte nicht vorbereitet werden."
case strings.Contains(strings.ToLower(s), "wine"):
return "Der Wine-Stack konnte den Selbsttest oder die Reparatur nicht abschließen."
default:
@@ -72,17 +179,52 @@ func friendlyActionError(action string, err error) string {
func (a *App) runGUI(args []string) error {
noOpen := hasArg(args, "--no-open")
guiLock, acquired, err := a.acquireGUILock()
if err != nil {
return err
}
if !acquired {
if existing, ok := a.existingGUIURL(); ok {
fmt.Println(existing)
if noOpen {
return nil
}
return openGUIURL(existing, a.cacheDir)
}
return errors.New("Citizen Launcher läuft bereits, reagiert aber noch nicht. Bitte kurz warten und erneut öffnen.")
}
defer releaseFileLock(guiLock)
defer os.Remove(a.guiInfoPath())
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
tokenBytes := make([]byte, 18)
_, _ = rand.Read(tokenBytes)
token := hex.EncodeToString(tokenBytes)
base := "/" + token
if _, err := rand.Read(tokenBytes); err != nil {
_ = listener.Close()
return fmt.Errorf("sicheres GUI-Token konnte nicht erzeugt werden: %w", err)
}
base := "/" + hex.EncodeToString(tokenBytes)
jobs := &jobStore{jobs: map[string]*GUIJob{}}
mux := http.NewServeMux()
sub, _ := fs.Sub(guiFiles, "web")
var activityMu sync.Mutex
lastActivity := time.Now()
touch := func() { activityMu.Lock(); lastActivity = time.Now(); activityMu.Unlock() }
secure := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
touch()
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
next.ServeHTTP(w, r)
})
}
mux.Handle(base+"/assets/", http.StripPrefix(base+"/assets/", http.FileServer(http.FS(sub))))
mux.HandleFunc(base+"/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != base+"/" {
@@ -102,109 +244,110 @@ func (a *App) runGUI(args []string) error {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
mux.HandleFunc(base+"/api/ping", func(w http.ResponseWriter, r *http.Request) {
jsonOut(w, map[string]any{"ok": true, "version": appVersion})
})
mux.HandleFunc(base+"/api/status", func(w http.ResponseWriter, r *http.Request) {
st, _ := a.status(false)
su, _ := a.selfUpdateStatus(false)
jsonOut(w, GUIStatus{
Version: appVersion, InstalledVersion: su.Installed, RestartRequired: su.RestartNeeded,
PackageAutoUpdate: packageAutoUpdateActive(), Platform: detectPlatform(), Game: a.gameStatus(), Launcher: st,
})
jsonOut(w, GUIStatus{Version: appVersion, InstalledVersion: su.Installed, RestartRequired: su.RestartNeeded,
PackageAutoUpdate: packageAutoUpdateActive(), Platform: detectPlatform(), Game: a.gameStatus(), Launcher: st, ActiveJob: jobs.active()})
})
mux.HandleFunc(base+"/api/job/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, base+"/api/job/")
jobs.mu.Lock()
j := jobs.jobs[id]
jobs.mu.Unlock()
j := jobs.get(strings.TrimPrefix(r.URL.Path, base+"/api/job/"))
if j == nil {
http.NotFound(w, r)
return
}
jsonOut(w, j)
})
var srv *http.Server
mux.HandleFunc(base+"/api/action/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
if r.Method != http.MethodPost {
http.Error(w, "POST required", 405)
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"
}
}
target := a.stableExecutable()
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) }()
go func() {
time.Sleep(350 * time.Millisecond)
_ = listener.Close()
if err := syscall.Exec(target, []string{target, "gui"}, os.Environ()); err != nil {
a.logf("GUI restart exec failed: %v", err)
os.Exit(1)
}
}()
return
}
if action == "launch" {
if active := jobs.active(); active != nil {
http.Error(w, friendlyActionName(active.Action)+" läuft noch", 409)
return
}
if err := a.gameLaunch(); err != nil {
if errors.Is(err, ErrLauncherAlreadyRunning) {
jsonOut(w, map[string]string{"state": "launcher-running"})
return
}
if errors.Is(err, ErrGameAlreadyRunning) {
jsonOut(w, map[string]string{"state": "game-running"})
return
}
http.Error(w, err.Error(), 500)
return
}
jsonOut(w, map[string]string{"state": "started"})
return
}
idBytes := make([]byte, 8)
_, _ = rand.Read(idBytes)
id := hex.EncodeToString(idBytes)
j := &GUIJob{ID: id, Action: action, State: "running", Started: time.Now().Format(time.RFC3339)}
jobs.mu.Lock()
jobs.jobs[id] = j
jobs.mu.Unlock()
j, err := jobs.start(action)
if err != nil {
http.Error(w, err.Error(), 409)
return
}
go func() {
var err error
var runErr error
var msg string
switch action {
case "setup":
if e := a.enableAutopilot(); e != nil {
a.logf("autopilot enable during setup: %v", e)
}
err = a.gameInstall()
runErr = a.gameInstall()
case "repair":
err = a.gameRepair()
runErr = a.gameRepair()
case "maintain":
err = a.maintainGamingStack()
runErr = a.maintainGamingStack()
case "doctor":
err = a.wineDoctor()
runErr = a.wineDoctor()
case "prepare-system":
runErr = a.ensureSystemPrepared()
case "choose-prefix":
msg, runErr = a.choosePrefixDirectory()
case "autopilot-enable":
err = a.enableAutopilot()
runErr = a.enableAutopilot()
case "autopilot-disable":
err = a.disableAutopilot()
runErr = a.disableAutopilot()
case "support":
var p string
p, err = a.createSupportBundle()
msg = p
msg, runErr = a.createSupportBundle()
case "integration-update":
err = a.update(false)
runErr = a.update(false)
default:
err = errors.New("unknown action")
}
jobs.mu.Lock()
defer jobs.mu.Unlock()
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"
j.Message = msg
runErr = errors.New("unknown action")
}
jobs.finish(j.ID, runErr, msg)
}()
jsonOut(w, j)
})
mux.HandleFunc(base+"/api/open/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
if r.Method != http.MethodPost {
http.Error(w, "POST required", 405)
return
}
@@ -219,11 +362,19 @@ func (a *App) runGUI(args []string) error {
http.NotFound(w, r)
return
}
_ = exec.Command("xdg-open", path).Start()
if err := exec.Command("xdg-open", path).Start(); err != nil {
http.Error(w, err.Error(), 500)
return
}
jsonOut(w, map[string]string{"state": "opened"})
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
srv = &http.Server{Handler: secure(mux), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
url := "http://" + listener.Addr().String() + base + "/"
if err := a.writeGUIInfo(guiInstanceInfo{PID: os.Getpid(), URL: url, Started: time.Now().Format(time.RFC3339)}); err != nil {
_ = listener.Close()
return err
}
fmt.Println(url)
if !noOpen {
if err := openGUIURL(url, a.cacheDir); err != nil {
@@ -231,16 +382,33 @@ func (a *App) runGUI(args []string) error {
return err
}
}
return srv.Serve(listener)
// Browser app windows do not give us a portable close notification. Status
// polling acts as a heartbeat. Never stop the backend while a long-running job is active.
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for range t.C {
activityMu.Lock()
idle := time.Since(lastActivity)
activityMu.Unlock()
if idle > 10*time.Minute && jobs.active() == nil {
_ = srv.Close()
return
}
}
}()
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
return err
}
return nil
}
func openGUIURL(url, cache string) error {
candidates := []string{"chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "brave-browser", "brave", "vivaldi", "vivaldi-stable"}
for _, name := range candidates {
if p, err := exec.LookPath(name); err == nil {
cmd := exec.Command(p, "--app="+url, "--new-window")
cmd.Stdout = nil
cmd.Stderr = nil
cmd := exec.Command(p, "--app="+url, "--new-window", "--class=CitizenLauncher")
if cmd.Start() == nil {
return nil
}
@@ -0,0 +1,301 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func testApp(t *testing.T) *App {
t.Helper()
root := t.TempDir()
self := filepath.Join(root, "bin", "citizen-launcher")
if err := os.MkdirAll(filepath.Dir(self), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(self, []byte("test"), 0o755); err != nil {
t.Fatal(err)
}
a := &App{
home: root,
configDir: filepath.Join(root, ".config", "citizen-launcher"),
stateDir: filepath.Join(root, ".local", "state", "citizen-launcher"),
dataDir: filepath.Join(root, ".local", "share", "citizen-launcher"),
cacheDir: filepath.Join(root, ".cache", "citizen-launcher"),
libDir: filepath.Join(root, ".local", "lib", "citizen-launcher"),
selfPath: self,
}
a.vendorDir = filepath.Join(a.dataDir, "vendor")
return a
}
func TestDesktopEntryHasRealNewlinesAndStableLaunch(t *testing.T) {
a := testApp(t)
gc := GameConfig{Prefix: filepath.Join(a.home, "Games", "star-citizen")}
if err := a.writeOwnedLaunchFiles(gc); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(a.starDesktopPath())
if err != nil {
t.Fatal(err)
}
s := string(b)
if strings.Contains(s, `\n`) {
t.Fatalf("desktop contains literal \\n: %q", s)
}
if !strings.Contains(s, "Exec=") || !strings.Contains(s, " game-launch\n") {
t.Fatalf("invalid Exec line: %q", s)
}
wrapper := filepath.Join(a.dataDir, "bin", "star-citizen-launch")
fi, err := os.Stat(wrapper)
if err != nil {
t.Fatal(err)
}
if fi.Mode()&0o111 == 0 {
t.Fatal("launch wrapper is not executable")
}
wb, _ := os.ReadFile(wrapper)
if strings.Contains(string(wb), `\n`) {
t.Fatalf("wrapper contains literal newline escape: %q", string(wb))
}
}
func TestDXVKStateRequiresDLLsAndOverrideMarker(t *testing.T) {
a := testApp(t)
prefix := filepath.Join(a.home, "Games", "star-citizen")
if err := os.MkdirAll(filepath.Join(prefix, "drive_c", "windows", "system32"), 0o755); err != nil {
t.Fatal(err)
}
for _, f := range []string{"system.reg", "user.reg"} {
if err := os.WriteFile(filepath.Join(prefix, f), []byte("reg"), 0o600); err != nil {
t.Fatal(err)
}
}
for _, f := range dxvkRequiredDLLs {
if err := os.WriteFile(filepath.Join(prefix, "drive_c", "windows", "system32", f), make([]byte, 2048), 0o644); err != nil {
t.Fatal(err)
}
}
if got := a.dxvkState(prefix, "v3.1"); got != "repair" {
t.Fatalf("without override marker got %q", got)
}
if err := atomicWriteFile(a.dxvkMarkerPath(), []byte("v3.1\n"+prefix+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := a.dxvkState(prefix, "v3.1"); got != "ready" {
t.Fatalf("complete DXVK got %q", got)
}
if err := os.Remove(filepath.Join(prefix, "drive_c", "windows", "system32", "dxgi.dll")); err != nil {
t.Fatal(err)
}
if got := a.dxvkState(prefix, "v3.1"); got != "repair" {
t.Fatalf("missing DLL got %q", got)
}
}
func TestEnvironmentReplacementKeepsManagedWinePath(t *testing.T) {
env := []string{"HOME=/tmp/x", "PATH=/managed/wine/bin:/usr/bin", "LD_LIBRARY_PATH=/managed/lib"}
env = setEnvValue(env, "PATH", "/toolbox/bin:"+envValue(env, "PATH"))
if got := envValue(env, "PATH"); got != "/toolbox/bin:/managed/wine/bin:/usr/bin" {
t.Fatalf("PATH lost runner: %q", got)
}
count := 0
for _, v := range env {
if strings.HasPrefix(v, "PATH=") {
count++
}
}
if count != 1 {
t.Fatalf("duplicate PATH entries: %#v", env)
}
}
func TestSingleInstanceAndStackLocks(t *testing.T) {
a := testApp(t)
first, ok, err := a.acquireGUILock()
if err != nil || !ok {
t.Fatalf("first GUI lock: ok=%v err=%v", ok, err)
}
defer releaseFileLock(first)
second, ok, err := a.acquireGUILock()
if err != nil || ok || second != nil {
t.Fatalf("second GUI lock must be refused: ok=%v err=%v", ok, err)
}
stack, err := a.stackOperationLock(20 * time.Millisecond)
if err != nil {
t.Fatal(err)
}
defer releaseFileLock(stack)
if other, err := a.stackOperationLock(20 * time.Millisecond); err == nil || other != nil {
t.Fatal("second stack lock unexpectedly succeeded")
}
}
func TestSupportSanitizer(t *testing.T) {
in := []byte("/home/tester/Games mail=a@example.com ip=192.168.1.4 MAC=aa:bb:cc:dd:ee:ff token=supersecret Bearer abc.def.ghi")
out := string(sanitizeSupport(in, "/home/tester"))
for _, secret := range []string{"/home/tester", "a@example.com", "192.168.1.4", "aa:bb:cc:dd:ee:ff", "supersecret", "Bearer abc.def.ghi"} {
if strings.Contains(out, secret) {
t.Fatalf("sanitizer leaked %q in %q", secret, out)
}
}
}
func TestReleaseRepoValidation(t *testing.T) {
for _, good := range []string{"owner/repo", "a-b/c_d", "org.name/project.name"} {
if !validReleaseRepo(good) {
t.Fatalf("valid repo rejected: %q", good)
}
}
for _, bad := range []string{"", "owner", "a/b/c", "https://evil/x", "a/../b", "a/b;cmd"} {
if validReleaseRepo(bad) {
t.Fatalf("invalid repo accepted: %q", bad)
}
}
}
func TestRootUpdaterIgnoresEnvironmentRepository(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("root-only trust-boundary test")
}
t.Setenv("CITIZEN_LAUNCHER_RELEASE_REPO", "evil/redirect")
if got := effectiveReleaseRepo(); got == "evil/redirect" {
t.Fatal("privileged updater trusted user environment")
}
}
func TestFrontendDoesNotDoubleBindPrimaryAction(t *testing.T) {
b, err := os.ReadFile(filepath.Join("web", "app.js"))
if err != nil {
t.Fatal(err)
}
s := string(b)
if strings.Contains(s, "$('primary').onclick") {
t.Fatal("primary action has a second direct click handler")
}
if !strings.Contains(s, "document.addEventListener('click'") {
t.Fatal("delegated action handler missing")
}
}
func TestDXVKNativeOverrideSetIsComplete(t *testing.T) {
want := []string{"d3d8", "d3d9", "d3d10core", "d3d11", "dxgi"}
if strings.Join(dxvkOverrideNames, ",") != strings.Join(want, ",") {
t.Fatalf("DXVK override set=%v", dxvkOverrideNames)
}
}
func TestProcessEnvMatchingIsExact(t *testing.T) {
prefix := "/home/user/Games/star-citizen"
env := []byte("HOME=/home/user\x00WINEPREFIX=" + prefix + "-old\x00PATH=/usr/bin\x00")
if processHasEnvValue(env, "WINEPREFIX", prefix) {
t.Fatal("prefix matcher accepted a longer different prefix")
}
env = append(env, []byte("WINEPREFIX="+prefix+"\x00")...)
if !processHasEnvValue(env, "WINEPREFIX", prefix) {
t.Fatal("prefix matcher missed exact WINEPREFIX")
}
}
func TestPinnedWinetricksIsIntegrityCheckedAndMinimal(t *testing.T) {
if winetricksVersion != "20260125" {
t.Fatalf("unexpected pinned Winetricks version %q", winetricksVersion)
}
if len(winetricksSHA256) != 64 {
t.Fatalf("Winetricks checksum length=%d", len(winetricksSHA256))
}
for _, v := range basePrefixWinetricksVerbs {
if strings.EqualFold(v, "powershell") || strings.EqualFold(v, "dxvk") {
t.Fatalf("fragile/duplicate Winetricks verb must not be in base prefix: %s", v)
}
}
}
func TestDebMigrationRemovesShadowBinaryAndLegacyDesktop(t *testing.T) {
a := testApp(t)
fakeBin := filepath.Join(t.TempDir(), "bin")
if err := os.MkdirAll(fakeBin, 0o755); err != nil {
t.Fatal(err)
}
dpkg := filepath.Join(fakeBin, "dpkg-query")
if err := os.WriteFile(dpkg, []byte("#!/bin/sh\nprintf 'install ok installed\\n1.0.0\\n'\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
oldBin := filepath.Join(a.home, ".local", "bin", "citizen-launcher")
if err := os.MkdirAll(filepath.Dir(oldBin), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(oldBin, []byte("#!/bin/sh\necho 0.9.2\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(a.mainDesktopPath()), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(a.mainDesktopPath(), []byte("[Desktop Entry]\nName=Citizen Launcher\nExec="+oldBin+" gui\n"), 0o644); err != nil {
t.Fatal(err)
}
a.migrateUserInstallUnlocked()
if _, err := os.Stat(oldBin); !os.IsNotExist(err) {
t.Fatalf("shadow binary survived migration: %v", err)
}
if _, err := os.Stat(a.mainDesktopPath()); !os.IsNotExist(err) {
t.Fatalf("legacy desktop survived migration: %v", err)
}
}
func TestRepairDesktopIntegrationFixesLiteralNewlineRegression(t *testing.T) {
a := testApp(t)
gc := a.loadGameConfig()
if err := os.MkdirAll(filepath.Dir(gc.LauncherEXE), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(gc.LauncherEXE, []byte("fixture"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(a.starDesktopPath()), 0o755); err != nil {
t.Fatal(err)
}
bad := "[Desktop Entry]\\nName=Star Citizen\\nExec=/old/star-citizen-launch\\n"
if err := os.WriteFile(a.starDesktopPath(), []byte(bad), 0o644); err != nil {
t.Fatal(err)
}
a.repairDesktopIntegrationUnlocked()
b, err := os.ReadFile(a.starDesktopPath())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), `\\n`) {
t.Fatalf("literal newline regression was not repaired: %q", b)
}
if !strings.Contains(string(b), "Exec=") || !strings.Contains(string(b), " game-launch\n") {
t.Fatalf("repaired desktop invalid: %q", b)
}
}
func TestCopyFileEnforcesModeOnExistingDestination(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src")
dst := filepath.Join(dir, "dst")
if err := os.WriteFile(src, []byte("payload"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := copyFile(src, dst, 0o755); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o755 {
t.Fatalf("copy mode=%o want 755", fi.Mode().Perm())
}
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
type guiInstanceInfo struct {
PID int `json:"pid"`
URL string `json:"url"`
Started string `json:"started"`
}
func (a *App) acquireGUILock() (*os.File, bool, error) {
if err := os.MkdirAll(a.stateDir, 0o755); err != nil {
return nil, false, err
}
f, err := os.OpenFile(filepath.Join(a.stateDir, "gui.lock"), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, false, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
_ = f.Close()
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
return nil, false, nil
}
return nil, false, err
}
return f, true, nil
}
func releaseFileLock(f *os.File) {
if f == nil {
return
}
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
}
func (a *App) guiInfoPath() string { return filepath.Join(a.stateDir, "gui-instance.json") }
func (a *App) writeGUIInfo(info guiInstanceInfo) error {
b, _ := json.MarshalIndent(info, "", " ")
b = append(b, '\n')
return atomicWriteFile(a.guiInfoPath(), b, 0o600)
}
func (a *App) existingGUIURL() (string, bool) {
for i := 0; i < 12; i++ {
b, err := os.ReadFile(a.guiInfoPath())
if err == nil {
var info guiInstanceInfo
if json.Unmarshal(b, &info) == nil && info.URL != "" && processAlive(info.PID) {
client := &http.Client{Timeout: 500 * time.Millisecond}
resp, err := client.Get(strings.TrimRight(info.URL, "/") + "/api/ping")
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return info.URL, true
}
}
}
}
time.Sleep(150 * time.Millisecond)
}
return "", false
}
func processAlive(pid int) bool {
if pid <= 1 {
return false
}
p, err := os.FindProcess(pid)
if err != nil {
return false
}
return p.Signal(syscall.Signal(0)) == nil
}
func atomicWriteFile(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
name := f.Name()
defer os.Remove(name)
if err := f.Chmod(mode); err != nil {
_ = f.Close()
return err
}
if _, err := f.Write(data); err != nil {
_ = f.Close()
return err
}
if err := f.Sync(); err != nil {
_ = f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(name, path)
}
var (
ErrLauncherAlreadyRunning = errors.New("RSI Launcher läuft bereits")
ErrGameAlreadyRunning = errors.New("Star Citizen läuft bereits")
)
func processHasEnvValue(data []byte, key, value string) bool {
want := key + "=" + value
for _, field := range strings.Split(string(data), "\x00") {
if field == want {
return true
}
}
return false
}
type prefixProcessState struct {
RSI bool
Game bool
}
func (a *App) prefixProcessState(prefix string) prefixProcessState {
var state prefixProcessState
entries, err := os.ReadDir("/proc")
if err != nil {
return state
}
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := strconv.Atoi(e.Name()); err != nil {
continue
}
env, err := os.ReadFile(filepath.Join("/proc", e.Name(), "environ"))
if err != nil || !processHasEnvValue(env, "WINEPREFIX", prefix) {
continue
}
cmdline, _ := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline"))
cmd := strings.ToLower(strings.ReplaceAll(string(cmdline), "\x00", " "))
switch {
case strings.Contains(cmd, "starcitizen.exe") || strings.Contains(cmd, "star citizen\\live\\bin64"):
state.Game = true
case strings.Contains(cmd, "rsi launcher") || strings.Contains(cmd, "rsilauncher"):
state.RSI = true
}
if state.RSI && state.Game {
return state
}
}
return state
}
func (a *App) rsiLauncherRunning(prefix string) bool { return a.prefixProcessState(prefix).RSI }
func (a *App) starCitizenRunning(prefix string) bool { return a.prefixProcessState(prefix).Game }
func (a *App) stackOperationLock(wait time.Duration) (*os.File, error) {
if err := os.MkdirAll(a.stateDir, 0o755); err != nil {
return nil, err
}
f, err := os.OpenFile(filepath.Join(a.stateDir, "maintenance.lock"), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
deadline := time.Now().Add(wait)
for {
err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err == nil {
return f, nil
}
if time.Now().After(deadline) {
_ = f.Close()
return nil, fmt.Errorf("Gaming-Stack wird gerade gewartet; bitte einen Moment warten")
}
time.Sleep(200 * time.Millisecond)
}
}
+293
View File
@@ -0,0 +1,293 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
)
const (
requiredMapCount = int64(16777216)
requiredNoFile = uint64(524288)
)
var safePrefixPath = regexp.MustCompile(`^[A-Za-z0-9_./-]+$`)
func validatePrefixPath(prefix string) error {
if prefix == "" || !filepath.IsAbs(prefix) {
return errors.New("Der Wine-Prefix benötigt einen absoluten Linux-Pfad")
}
if filepath.Clean(prefix) != prefix {
return errors.New("Der Installationspfad ist nicht normalisiert")
}
if !safePrefixPath.MatchString(prefix) {
return errors.New("Der Star-Citizen-Pfad darf keine Leerzeichen, Umlaute oder Sonderzeichen enthalten. Empfohlen: ~/Games/star-citizen")
}
if fi, err := os.Lstat(prefix); err == nil && fi.Mode()&os.ModeSymlink != 0 {
return errors.New("Der Wine-Prefix darf kein symbolischer Link sein")
}
return nil
}
func rotateFile(path string, maxBytes, keepBytes int64) {
if maxBytes <= 0 || keepBytes <= 0 || keepBytes >= maxBytes {
return
}
fi, err := os.Stat(path)
if err != nil || fi.Size() <= maxBytes {
return
}
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
if _, err := f.Seek(-keepBytes, 2); err != nil {
return
}
b, err := io.ReadAll(f)
if err != nil {
return
}
_ = atomicWriteFile(path, b, 0o600)
}
// stableExecutable returns a path that survives application upgrades.
// Package installs always prefer /usr/bin, user installs prefer ~/.local/bin.
func (a *App) stableExecutable() string {
if installedPackageVersion() != "" {
if fi, err := os.Stat("/usr/bin/citizen-launcher"); err == nil && fi.Mode().IsRegular() {
return "/usr/bin/citizen-launcher"
}
}
userBin := filepath.Join(a.home, ".local", "bin", "citizen-launcher")
if fi, err := os.Stat(userBin); err == nil && fi.Mode().IsRegular() {
return userBin
}
if a.selfPath != "" {
return a.selfPath
}
return filepath.Join(a.libDir, "citizen-launcher")
}
func desktopExecQuote(s string) string {
// Desktop Entry Exec quoting follows a shell-like double-quoted subset.
repl := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", "$", "\\$")
return `"` + repl.Replace(s) + `"`
}
func (a *App) userApplicationsDir() string {
return filepath.Join(envOr("XDG_DATA_HOME", filepath.Join(a.home, ".local", "share")), "applications")
}
func (a *App) mainDesktopPath() string {
return filepath.Join(a.userApplicationsDir(), appID+".desktop")
}
func (a *App) starDesktopPath() string {
return filepath.Join(a.userApplicationsDir(), "citizen-launcher-star-citizen.desktop")
}
// migrateUserInstall removes the old ~/.local shadow install when a Debian
// package is now authoritative. It only removes files that identify themselves
// as Citizen Launcher and are not newer than the installed package.
func (a *App) migrateUserInstall() {
if os.Geteuid() == 0 {
return
}
a.migrateUserInstallUnlocked()
}
func (a *App) migrateUserInstallUnlocked() {
if installedPackageVersion() == "" {
return
}
installed := installedPackageVersion()
oldBin := filepath.Join(a.home, ".local", "bin", "citizen-launcher")
if oldBin != a.selfPath {
if out, err := exec.Command(oldBin, "--version").CombinedOutput(); err == nil {
v := strings.TrimSpace(string(out))
if v != "" && compareVersions(v, installed) <= 0 {
if err := os.Remove(oldBin); err == nil {
a.logf("removed shadowed legacy user binary %s version=%s", oldBin, v)
}
}
}
}
// A user-local desktop entry overrides the system package entry. Remove only
// the legacy Citizen Launcher entry, never arbitrary user shortcuts.
if b, err := os.ReadFile(a.mainDesktopPath()); err == nil {
s := string(b)
if strings.Contains(s, "Name=Citizen Launcher") && strings.Contains(s, "citizen-launcher") {
_ = os.Remove(a.mainDesktopPath())
a.logf("removed legacy user desktop entry so packaged desktop entry can take precedence")
}
}
// Rewrite old per-user systemd units to the stable package binary if they exist.
service := filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.service")
if b, err := os.ReadFile(service); err == nil && strings.Contains(string(b), ".local/lib/citizen-launcher") {
if err := a.installService(); err != nil {
a.logf("legacy user service migration warning: %v", err)
}
}
}
// repairDesktopIntegration repairs files created by older versions. It is safe
// to call on every user-level launcher start and does not touch game data.
func (a *App) repairDesktopIntegration() {
if os.Geteuid() == 0 {
return
}
a.repairDesktopIntegrationUnlocked()
}
func (a *App) repairDesktopIntegrationUnlocked() {
gc := a.loadGameConfig()
if _, err := os.Stat(gc.LauncherEXE); err == nil || gc.InstalledAt != "" {
if err := a.writeOwnedLaunchFiles(gc); err != nil {
a.logf("desktop integration repair warning: %v", err)
}
}
}
func (a *App) choosePrefixDirectory() (string, error) {
gc := a.loadGameConfig()
if prefixInitialized(gc.Prefix) || gc.LauncherStateReady() {
return "", errors.New("Der Installationsort kann nach der Einrichtung nicht automatisch verschoben werden")
}
start := filepath.Dir(gc.Prefix)
var cmd *exec.Cmd
if p, err := exec.LookPath("kdialog"); err == nil {
cmd = exec.Command(p, "--getexistingdirectory", start, "--title", "Star-Citizen-Installationsordner wählen")
} else if p, err := exec.LookPath("zenity"); err == nil {
cmd = exec.Command(p, "--file-selection", "--directory", "--title=Star-Citizen-Installationsordner wählen", "--filename="+start+string(os.PathSeparator))
} else {
return "", errors.New("Kein grafischer Ordnerdialog gefunden (kdialog oder zenity)")
}
out, err := cmd.Output()
if err != nil {
return "", errors.New("Ordnerauswahl abgebrochen")
}
parent := strings.TrimSpace(string(out))
if parent == "" || !filepath.IsAbs(parent) {
return "", errors.New("Ungültiger Installationsordner")
}
prefix := filepath.Clean(filepath.Join(parent, "star-citizen"))
if err := validatePrefixPath(prefix); err != nil {
return "", err
}
gc.Prefix = prefix
gc.GameDir = filepath.Join(prefix, "drive_c", "Program Files", "Roberts Space Industries", "StarCitizen")
gc.LauncherEXE = filepath.Join(prefix, "drive_c", "Program Files", "Roberts Space Industries", "RSI Launcher", "RSI Launcher.exe")
if err := a.saveGameConfig(gc); err != nil {
return "", err
}
return prefix, nil
}
func (g GameConfig) LauncherStateReady() bool {
_, err := os.Stat(g.LauncherEXE)
return err == nil
}
// setLaunchNoFile raises the soft file limit for Wine when the session hard
// limit already permits it. Package installations also ship a PAM limits file
// so future sessions get the required hard limit automatically.
func setLaunchNoFile() error {
var lim syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil {
return err
}
if lim.Max < requiredNoFile {
return fmt.Errorf("Hard-Limit für offene Dateien ist %d, benötigt werden %d", lim.Max, requiredNoFile)
}
if lim.Cur < requiredNoFile {
lim.Cur = requiredNoFile
if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil {
return err
}
}
return nil
}
func (a *App) prepareSystem(pid int) error {
if os.Geteuid() == 0 {
return prepareSystemRoot(pid)
}
if !commandExists("pkexec") {
return errors.New("Für die einmalige Systemvorbereitung wird Polkit/pkexec benötigt")
}
self := a.stableExecutable()
targetPID := pid
if targetPID <= 1 {
targetPID = os.Getpid()
}
args := []string{self, "prepare-system", "--root", "--pid", strconv.Itoa(targetPID)}
cmd := exec.Command("pkexec", args...)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return cmd.Run()
}
func prepareSystemRoot(targetPID int) error {
if os.Geteuid() != 0 {
return errors.New("prepare-system --root requires root")
}
const sysctlPath = "/etc/sysctl.d/90-citizen-launcher.conf"
const limitsPath = "/etc/security/limits.d/90-citizen-launcher.conf"
if err := os.WriteFile(sysctlPath, []byte("# Citizen Launcher / Star Citizen\nvm.max_map_count = 16777216\n"), 0o644); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(limitsPath), 0o755); err != nil {
return err
}
limits := "# Citizen Launcher / Star Citizen\n* soft nofile 524288\n* hard nofile 524288\n"
if err := os.WriteFile(limitsPath, []byte(limits), 0o644); err != nil {
return err
}
if commandExists("sysctl") {
if out, err := exec.Command("sysctl", "-w", "vm.max_map_count=16777216").CombinedOutput(); err != nil {
return fmt.Errorf("vm.max_map_count: %s", formatCommandFailure(err, out))
}
}
// Best-effort immediate upgrade of the caller's rlimit. Validate ownership
// using PKEXEC_UID before touching another process.
if targetPID > 1 && commandExists("prlimit") {
uidText := strings.TrimSpace(os.Getenv("PKEXEC_UID"))
if uidText != "" {
status, _ := os.ReadFile(filepath.Join("/proc", strconv.Itoa(targetPID), "status"))
if strings.Contains(string(status), "Uid:\t"+uidText+"\t") {
_ = exec.Command("prlimit", "--pid", strconv.Itoa(targetPID), "--nofile=524288:524288").Run()
}
}
}
return nil
}
func (a *App) ensureSystemPrepared() error {
r := a.systemReadiness(a.loadGameConfig().Prefix)
if r.MapCountOK && r.NoFileHardOK {
return nil
}
if err := a.prepareSystem(os.Getpid()); err != nil {
return err
}
// vm.max_map_count applies immediately. PAM limits may require a new login,
// but try to raise the current process immediately when permitted.
_ = setLaunchNoFile()
time.Sleep(100 * time.Millisecond)
r = a.systemReadiness(a.loadGameConfig().Prefix)
if !r.MapCountOK {
return fmt.Errorf("vm.max_map_count ist weiterhin zu niedrig (%d)", r.MapCount)
}
return nil
}
+49 -23
View File
@@ -16,7 +16,7 @@ import (
)
var (
appVersion = "0.9.2"
appVersion = "1.0.0"
releaseRepo = "sendnwv/omarchy-sc"
)
@@ -80,6 +80,10 @@ func main() {
fatal(err)
}
app.rotateLog()
if os.Geteuid() != 0 {
app.migrateUserInstall()
app.repairDesktopIntegration()
}
args := os.Args[1:]
if len(args) == 0 {
@@ -168,12 +172,33 @@ func main() {
}
case "game-launch":
if err := app.gameLaunch(); err != nil {
if errors.Is(err, ErrLauncherAlreadyRunning) || errors.Is(err, ErrGameAlreadyRunning) {
fmt.Println(err)
break
}
fatal(err)
}
case "game-repair":
if err := app.gameRepair(); err != nil {
fatal(err)
}
case "prepare-system":
pid := 0
for i := 1; i+1 < len(args); i++ {
if args[i] == "--pid" {
fmt.Sscanf(args[i+1], "%d", &pid)
}
}
if hasArg(args[1:], "--root") {
if err := prepareSystemRoot(pid); err != nil {
fatal(err)
}
} else if err := app.prepareSystem(pid); err != nil {
fatal(err)
}
case "migrate-user":
app.migrateUserInstall()
app.repairDesktopIntegration()
case "gui":
if err := app.runGUI(args[1:]); err != nil {
fatal(err)
@@ -512,15 +537,12 @@ func (a *App) enableAuto() error {
if err := a.validatePlugin(); err != nil {
return err
}
if err := a.installService(); err != nil {
return err
}
a.updateConfig(func(c *Config) {
c.AutoApply = true
c.TrustedRemote = st.Remote
c.LastResult = "auto-enabled"
})
if err := run("", "systemctl", "--user", "enable", "--now", "citizen-launcher-maintenance.timer"); err != nil {
if err := a.syncMaintenanceTimer(); err != nil {
return err
}
a.logf("auto updates enabled trusted_remote=%q", st.Remote)
@@ -533,7 +555,9 @@ func (a *App) disableAuto() error {
c.AutoApply = false
c.LastResult = "auto-disabled"
})
_ = run("", "systemctl", "--user", "disable", "--now", "citizen-launcher-maintenance.timer")
if err := a.syncMaintenanceTimer(); err != nil {
return err
}
a.logf("auto updates disabled")
fmt.Println("disabled")
return nil
@@ -543,24 +567,17 @@ func (a *App) installService() error {
if err := os.MkdirAll(filepath.Join(a.home, ".config", "systemd", "user"), 0o755); err != nil {
return err
}
if err := os.MkdirAll(a.libDir, 0o755); err != nil {
return err
}
if err := a.selfSync(); err != nil {
return err
}
servicePath := filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.service")
timerPath := filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.timer")
backendPath := filepath.Join(a.libDir, "citizen-launcher")
backendPath := a.stableExecutable()
service := `[Unit]\nDescription=Citizen Launcher Autopilot maintenance\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart=` + backendPath + ` tick\n`
timer := `[Unit]\nDescription=Maintain Citizen Launcher gaming stack automatically\n\n[Timer]\nOnBootSec=3min\nOnUnitActiveSec=6h\nRandomizedDelaySec=15min\nPersistent=true\nUnit=citizen-launcher-maintenance.service\n\n[Install]\nWantedBy=timers.target\n`
service := "[Unit]\nDescription=Citizen Launcher Autopilot maintenance\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart=" + backendPath + " tick\nNice=10\nIOSchedulingClass=best-effort\nIOSchedulingPriority=7\n\n"
timer := "[Unit]\nDescription=Maintain Citizen Launcher gaming stack automatically\n\n[Timer]\nOnBootSec=10min\nOnUnitActiveSec=6h\nRandomizedDelaySec=15min\nPersistent=true\nUnit=citizen-launcher-maintenance.service\n\n[Install]\nWantedBy=timers.target\n"
if err := os.WriteFile(servicePath, []byte(strings.ReplaceAll(service, `\n`, "\n")), 0o644); err != nil {
if err := os.WriteFile(servicePath, []byte(service), 0o644); err != nil {
return err
}
if err := os.WriteFile(timerPath, []byte(strings.ReplaceAll(timer, `\n`, "\n")), 0o644); err != nil {
if err := os.WriteFile(timerPath, []byte(timer), 0o644); err != nil {
return err
}
return run("", "systemctl", "--user", "daemon-reload")
@@ -590,7 +607,13 @@ func (a *App) selfSync() error {
}
same, _ := sameFileHash(source, target)
if !same {
tmp := target + ".new"
tmpFile, err := os.CreateTemp(a.libDir, ".citizen-launcher.*.new")
if err != nil {
return err
}
tmp := tmpFile.Name()
_ = tmpFile.Close()
defer os.Remove(tmp)
if err := copyFile(source, tmp, 0o755); err != nil {
return err
}
@@ -673,10 +696,7 @@ func (a *App) updateConfig(fn func(*Config)) {
_ = os.MkdirAll(a.configDir, 0o755)
data, _ := json.MarshalIndent(cfg, "", " ")
data = append(data, '\n')
tmp := a.configPath + ".tmp"
if os.WriteFile(tmp, data, 0o600) == nil {
_ = os.Rename(tmp, a.configPath)
}
_ = atomicWriteFile(a.configPath, data, 0o600)
}
func (a *App) rotateLog() {
@@ -775,6 +795,12 @@ func copyFile(src, dst string, mode os.FileMode) error {
out.Close()
return err
}
// OpenFile's mode is ignored when dst already exists (for example a
// CreateTemp staging file). Always enforce the requested final mode.
if err := out.Chmod(mode); err != nil {
out.Close()
return err
}
if err := out.Sync(); err != nil {
out.Close()
return err
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"bufio"
"errors"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
)
type SystemReadiness struct {
State string `json:"state"`
MapCount int64 `json:"vm_max_map_count"`
MapCountOK bool `json:"vm_max_map_count_ok"`
NoFileSoft uint64 `json:"nofile_soft"`
NoFileHard uint64 `json:"nofile_hard"`
NoFileHardOK bool `json:"nofile_hard_ok"`
Filesystem string `json:"filesystem,omitempty"`
MountOptions string `json:"mount_options,omitempty"`
FilesystemOK bool `json:"filesystem_ok"`
StorageFreeGiB int `json:"storage_free_gib"`
Reason string `json:"reason,omitempty"`
}
func (a *App) systemReadiness(prefix string) SystemReadiness {
r := SystemReadiness{State: "ready", FilesystemOK: true}
if b, err := os.ReadFile("/proc/sys/vm/max_map_count"); err == nil {
r.MapCount, _ = strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
}
r.MapCountOK = r.MapCount >= requiredMapCount
var lim syscall.Rlimit
if syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim) == nil {
r.NoFileSoft, r.NoFileHard = lim.Cur, lim.Max
}
r.NoFileHardOK = r.NoFileHard >= requiredNoFile
existing := nearestExistingPath(prefix)
if existing != "" {
if fs, opts, err := mountInfo(existing); err == nil {
r.Filesystem, r.MountOptions = fs, opts
low := strings.ToLower(fs)
if low == "ntfs" || low == "ntfs3" || low == "fuseblk" || low == "exfat" || optionPresent(opts, "noexec") {
r.FilesystemOK = false
if optionPresent(opts, "noexec") {
r.Reason = "Der Installationsdatenträger ist mit noexec eingehängt. Wine-Runner können dort nicht zuverlässig ausgeführt werden."
} else {
r.Reason = "Star Citizen sollte nicht auf NTFS/exFAT installiert werden. Bitte einen Linux-Datenträger verwenden."
}
}
}
}
var s syscallStatfs
if statfs(prefix, &s) != nil {
_ = statfs(existing, &s)
}
if s.Bsize > 0 {
r.StorageFreeGiB = int((s.Bavail * uint64(s.Bsize)) / (1024 * 1024 * 1024))
}
switch {
case !r.FilesystemOK:
r.State = "blocked"
case !r.MapCountOK || !r.NoFileHardOK:
r.State = "prepare"
}
return r
}
func nearestExistingPath(path string) string {
p := path
for p != "" && p != string(filepath.Separator) {
if _, err := os.Stat(p); err == nil {
return p
}
next := filepath.Dir(p)
if next == p {
break
}
p = next
}
if _, err := os.Stat(string(filepath.Separator)); err == nil {
return string(filepath.Separator)
}
return ""
}
func mountInfo(path string) (string, string, error) {
if commandExists("findmnt") {
out, err := exec.Command("findmnt", "-T", path, "-n", "-o", "FSTYPE,OPTIONS").CombinedOutput()
if err == nil {
fields := strings.Fields(strings.TrimSpace(string(out)))
if len(fields) >= 2 {
return fields[0], fields[1], nil
}
}
}
// /proc/mounts fallback: choose the longest matching mountpoint.
f, err := os.Open("/proc/mounts")
if err != nil {
return "", "", err
}
defer f.Close()
bestMount, bestFS, bestOpts := "", "", ""
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 4 {
continue
}
mount := strings.ReplaceAll(fields[1], `\040`, " ")
if path == mount || strings.HasPrefix(path, strings.TrimRight(mount, "/")+"/") {
if len(mount) > len(bestMount) {
bestMount, bestFS, bestOpts = mount, fields[2], fields[3]
}
}
}
if bestMount == "" {
return "", "", errors.New("mount point not found")
}
return bestFS, bestOpts, nil
}
func optionPresent(opts, want string) bool {
for _, o := range strings.Split(opts, ",") {
if o == want {
return true
}
}
return false
}
+46 -10
View File
@@ -12,6 +12,7 @@ import (
"regexp"
"strconv"
"strings"
"syscall"
"time"
)
@@ -36,16 +37,37 @@ func packageAutoUpdateActive() bool {
return exec.Command("systemctl", "is-enabled", "citizen-launcher-self-update.timer").Run() == nil
}
var releaseRepoPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
func validReleaseRepo(v string) bool { return releaseRepoPattern.MatchString(strings.TrimSpace(v)) }
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 != "" {
// 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
}
}
return releaseRepo
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 "sendnwv/omarchy-sc"
}
func (a *App) selfUpdateStatus(fetch bool) (SelfUpdateStatus, error) {
@@ -175,6 +197,9 @@ func (a *App) applySelfUpdate(system, quiet bool) error {
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
@@ -231,8 +256,14 @@ func (a *App) applyDebUpdate(asset githubAsset, version string, quiet bool) erro
)
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
if quiet {
cmd.Stdout = a.logWriter()
cmd.Stderr = a.logWriter()
if os.Geteuid() == 0 {
// The package updater runs with ProtectHome=true. Log to the systemd
// journal instead of trying to write below /root.
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
} else {
cmd.Stdout = a.logWriter()
cmd.Stderr = a.logWriter()
}
} else {
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
}
@@ -272,8 +303,13 @@ func verifyDebPackage(path, version string) error {
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)
versionOK := pkgVersion == version
if !versionOK {
rev := regexp.MustCompile(`^` + regexp.QuoteMeta(version) + `-[0-9]+$`)
versionOK = rev.MatchString(pkgVersion)
}
if !versionOK {
return fmt.Errorf("package version %q does not match release %q", pkgVersion, version)
}
if arch != "amd64" {
return fmt.Errorf("refusing architecture %q", arch)
@@ -63,3 +63,25 @@ func TestVerifyDebPackageMetadata(t *testing.T) {
t.Fatal(err)
}
}
func TestVerifyDebPackageRejectsMismatchedNewerVersion(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: 9.9.9\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_1.0.0_amd64.deb")
if out, err := exec.Command("dpkg-deb", "--build", "--root-owner-group", pkg, deb).CombinedOutput(); err != nil {
t.Fatalf("dpkg-deb: %v: %s", err, out)
}
if err := verifyDebPackage(deb, "1.0.0"); err == nil {
t.Fatal("mismatched package version was accepted")
}
}
+642 -103
View File
@@ -1,6 +1,7 @@
package main
import (
"archive/zip"
"bufio"
"crypto/sha512"
"encoding/base64"
@@ -18,22 +19,32 @@ import (
"sort"
"strconv"
"strings"
"syscall"
"time"
)
const (
winetricksRepo = "Winetricks/winetricks"
rsiBaseURL = "https://install.robertsspaceindustries.com/rel/2"
rsiLatestYML = rsiBaseURL + "/latest.yml"
winetricksVersion = "20260125"
winetricksSHA256 = "431f82fc74000e6c864409f1d8fb495d696c03928808e3e8acffc45179312a7b"
rsiBaseURL = "https://install.robertsspaceindustries.com/rel/2"
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.
// Keep Winetricks focused on small, proven prefix tweaks. PowerShell is managed
// separately below because the MSI-based Winetricks path has failed on otherwise
// healthy modern Wine builds. We install the official portable PowerShell Core
// ZIP plus the RSI-compatible PowerShell wrapper instead.
var basePrefixWinetricksVerbs = []string{"arial", "tahoma", "win11"}
const wineFileAssociationsKey = "HKEY_CURRENT_USER\\Software\\Wine\\FileOpenAssociations"
const (
wineFileAssociationsKey = "HKEY_CURRENT_USER\\Software\\Wine\\FileOpenAssociations"
powerShellCoreVersion = "7.4.19"
powerShellCoreURL = "https://github.com/PowerShell/PowerShell/releases/download/v7.4.19/PowerShell-7.4.19-win-x64.zip"
powerShellCoreSHA256 = "cd62ad6d8174cc6fb85b335a0058444bc934fe27c39fa97fe342134286d28af9"
powerShellWrapperVersion = "3.0.5"
powerShellWrapperURL = "https://github.com/ProjectSynchro/powershell-wrapper-for-wine/releases/download/v3.0.5/powershell-wrapper.zip"
powerShellWrapperSHA256 = "08f866265e0395f4bc5ddb18f3dff345771d039a62911e506c62084fd2533ec3"
)
type GameConfig struct {
Prefix string `json:"prefix"`
@@ -46,25 +57,28 @@ type GameConfig struct {
}
type GameStatus struct {
BackendVersion string `json:"backend_version"`
Health string `json:"health"`
Hardware string `json:"hardware"`
HardwareReason string `json:"hardware_reason,omitempty"`
GPU string `json:"gpu,omitempty"`
Vulkan string `json:"vulkan,omitempty"`
CPUAVX bool `json:"cpu_avx"`
RAMGiB int `json:"ram_gib"`
CombinedGiB int `json:"combined_gib"`
DiskFreeGiB int `json:"disk_free_gib"`
Prefix string `json:"prefix"`
PrefixState string `json:"prefix_state"`
LauncherState string `json:"launcher_state"`
GameState string `json:"game_state"`
WineVersion string `json:"wine_version,omitempty"`
DXVKVersion string `json:"dxvk_version,omitempty"`
LUGVersion string `json:"lug_version,omitempty"`
RSIInstaller string `json:"rsi_installer,omitempty"`
Autopilot bool `json:"autopilot"`
BackendVersion string `json:"backend_version"`
Health string `json:"health"`
Hardware string `json:"hardware"`
HardwareReason string `json:"hardware_reason,omitempty"`
GPU string `json:"gpu,omitempty"`
Vulkan string `json:"vulkan,omitempty"`
CPUAVX bool `json:"cpu_avx"`
RAMGiB int `json:"ram_gib"`
CombinedGiB int `json:"combined_gib"`
DiskFreeGiB int `json:"disk_free_gib"`
Prefix string `json:"prefix"`
PrefixState string `json:"prefix_state"`
LauncherState string `json:"launcher_state"`
GameState string `json:"game_state"`
WineVersion string `json:"wine_version,omitempty"`
DXVKVersion string `json:"dxvk_version,omitempty"`
DXVKState string `json:"dxvk_state"`
PowerShellState string `json:"powershell_state"`
LUGVersion string `json:"lug_version,omitempty"`
RSIInstaller string `json:"rsi_installer,omitempty"`
Autopilot bool `json:"autopilot"`
System SystemReadiness `json:"system"`
}
func (a *App) gameConfigPath() string { return filepath.Join(a.configDir, "game.json") }
@@ -101,47 +115,61 @@ func (a *App) saveGameConfig(cfg GameConfig) error {
return err
}
data = append(data, '\n')
tmp := a.gameConfigPath() + ".new"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, a.gameConfigPath())
return atomicWriteFile(a.gameConfigPath(), data, 0o600)
}
func (a *App) gameStatus() GameStatus {
gc := a.loadGameConfig()
uc := a.loadConfig()
hs := a.hardwareStatus(gc.Prefix)
sys := a.systemReadiness(gc.Prefix)
st := GameStatus{
BackendVersion: appVersion,
Health: "setup",
Hardware: hs.State,
HardwareReason: hs.Reason,
GPU: hs.GPU,
Vulkan: hs.Vulkan,
CPUAVX: hs.AVX,
RAMGiB: hs.RAMGiB,
CombinedGiB: hs.CombinedGiB,
DiskFreeGiB: hs.DiskFreeGiB,
Prefix: gc.Prefix,
PrefixState: "missing",
LauncherState: "missing",
GameState: "missing",
WineVersion: readMetaVersion(filepath.Join(a.vendorDir, "wine", "meta.json")),
DXVKVersion: readMetaVersion(filepath.Join(a.vendorDir, "dxvk", "meta.json")),
LUGVersion: readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json")),
RSIInstaller: gc.RSIInstaller,
Autopilot: uc.AutoMaintain,
BackendVersion: appVersion,
Health: "setup",
Hardware: hs.State,
HardwareReason: hs.Reason,
GPU: hs.GPU,
Vulkan: hs.Vulkan,
CPUAVX: hs.AVX,
RAMGiB: hs.RAMGiB,
CombinedGiB: hs.CombinedGiB,
DiskFreeGiB: hs.DiskFreeGiB,
Prefix: gc.Prefix,
PrefixState: "missing",
LauncherState: "missing",
GameState: "missing",
WineVersion: readMetaVersion(filepath.Join(a.vendorDir, "wine", "meta.json")),
DXVKVersion: readMetaVersion(filepath.Join(a.vendorDir, "dxvk", "meta.json")),
DXVKState: "missing",
PowerShellState: "missing",
LUGVersion: readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json")),
RSIInstaller: gc.RSIInstaller,
Autopilot: uc.AutoMaintain,
System: sys,
}
if err := validatePrefixPath(gc.Prefix); err != nil {
st.Health = "hardware-blocked"
st.HardwareReason = err.Error()
return st
}
if hs.State == "blocked" {
st.Health = "hardware-blocked"
return st
}
if sys.State == "blocked" {
st.Health = "hardware-blocked"
st.HardwareReason = sys.Reason
return st
}
if prefixInitialized(gc.Prefix) {
st.PrefixState = "ready"
} else if fi, err := os.Stat(gc.Prefix); err == nil && fi.IsDir() {
st.PrefixState = "partial"
}
if st.PrefixState == "ready" {
st.DXVKState = a.dxvkState(gc.Prefix, st.DXVKVersion)
st.PowerShellState = a.powerShellState(gc.Prefix)
}
if _, err := os.Stat(gc.LauncherEXE); err == nil {
st.LauncherState = "ready"
}
@@ -151,13 +179,28 @@ func (a *App) gameStatus() GameStatus {
st.GameState = "partial"
}
// Around 150 GiB of free space is the current LUG quick-start recommendation
// for a fresh install. Do not block an already-installed game because free
// space naturally drops after installation.
if st.GameState == "missing" && sys.StorageFreeGiB > 0 && sys.StorageFreeGiB < 150 {
st.Health = "hardware-blocked"
st.HardwareReason = fmt.Sprintf("Für eine Neuinstallation sind ungefähr 150 GiB freier Speicher empfohlen; erkannt wurden %d GiB.", sys.StorageFreeGiB)
return st
}
switch {
case sys.State == "prepare":
st.Health = "system-prepare"
case st.WineVersion == "":
st.Health = "setup"
case st.PrefixState == "partial":
st.Health = "repair"
case st.PrefixState != "ready":
st.Health = "install"
case st.DXVKState != "ready":
st.Health = "repair"
case st.PowerShellState != "ready":
st.Health = "repair"
case st.LauncherState != "ready":
st.Health = "launcher-repair"
case st.GameState != "ready":
@@ -206,9 +249,12 @@ func (a *App) hardwareStatus(prefix string) hardwareInfo {
}
h.RAMGiB = int(vals["MemTotal"] / (1024 * 1024))
h.CombinedGiB = int((vals["MemTotal"] + vals["SwapTotal"]) / (1024 * 1024))
if h.RAMGiB < 14 && h.State != "blocked" {
if h.RAMGiB < 16 && h.State != "blocked" {
h.State = "blocked"
h.Reason = "Weniger als 16 GiB RAM erkannt."
} else if h.CombinedGiB < 48 && h.State != "blocked" {
h.State = "blocked"
h.Reason = fmt.Sprintf("RAM + Swap/ZRAM ergeben %d GiB; für Star Citizen werden mindestens 48 GiB benötigt.", h.CombinedGiB)
}
}
var stat syscallStatfs
@@ -251,12 +297,33 @@ func (a *App) hardwareStatus(prefix string) hardwareInfo {
if path, err := exec.LookPath("vulkaninfo"); err == nil {
cmd := exec.Command(path, "--summary")
out, err := cmd.CombinedOutput()
if err != nil || strings.Contains(string(out), "VK_ERROR_INCOMPATIBLE_DRIVER") {
text := string(out)
low := strings.ToLower(text)
if err != nil || strings.Contains(text, "VK_ERROR_INCOMPATIBLE_DRIVER") {
h.State = "blocked"
h.Vulkan = "blocked"
h.Reason = "Vulkan ist nicht funktionsfähig."
return h
}
if strings.Contains(low, "device_type_cpu") || strings.Contains(low, "llvmpipe") || strings.Contains(low, "lavapipe") || strings.Contains(low, "softpipe") {
h.State = "blocked"
h.Vulkan = "blocked"
h.Reason = "Es wurde nur ein Software-Vulkan-Gerät erkannt. Star Citizen benötigt eine echte Vulkan-GPU."
return h
}
if m := regexp.MustCompile(`(?m)deviceName\s*=\s*(.+)$`).FindStringSubmatch(text); len(m) == 2 {
h.GPU = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)apiVersion\s*=\s*([0-9]+)\.([0-9]+)`).FindStringSubmatch(text); len(m) == 3 {
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
if major < 1 || (major == 1 && minor < 3) {
h.State = "blocked"
h.Vulkan = "blocked"
h.Reason = fmt.Sprintf("Vulkan %d.%d erkannt; der aktuelle DXVK-Stack benötigt Vulkan 1.3 oder neuer.", major, minor)
return h
}
}
h.Vulkan = "ready"
} else {
matches, _ := filepath.Glob("/usr/share/vulkan/icd.d/*.json")
@@ -308,13 +375,44 @@ func (a *App) currentRunner() (string, error) {
return p, nil
}
func envValue(env []string, key string) string {
for i := len(env) - 1; i >= 0; i-- {
k, v, ok := strings.Cut(env[i], "=")
if ok && k == key {
return v
}
}
return ""
}
func setEnvValue(env []string, key, value string) []string {
env = environmentWithout(env, key)
return append(env, key+"="+value)
}
func environmentWithout(env []string, keys ...string) []string {
remove := map[string]bool{}
for _, k := range keys {
remove[k] = true
}
out := make([]string, 0, len(env))
for _, item := range env {
k, _, ok := strings.Cut(item, "=")
if ok && remove[k] {
continue
}
out = append(out, item)
}
return out
}
func (a *App) runnerEnv(prefix string) ([]string, string, error) {
runner, err := a.currentRunner()
if err != nil {
return nil, "", err
}
bin := filepath.Join(runner, "bin")
env := append([]string{}, os.Environ()...)
env := environmentWithout(os.Environ(), "SDL_VIDEODRIVER", "WINE", "WINESERVER", "WINEPREFIX", "WINEARCH", "WINEDEBUG", "WINEDLLOVERRIDES")
env = append(env,
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"WINE="+filepath.Join(bin, "wine"),
@@ -394,42 +492,50 @@ func (a *App) toolsEnv(base []string) ([]string, error) {
dirs := []string{filepath.Join(runtimeRoot, "usr", "bin"), filepath.Join(runtimeRoot, "bin"), filepath.Join(runtimeRoot, "usr", "sbin")}
libdirs := []string{filepath.Join(runtimeRoot, "usr", "lib"), filepath.Join(runtimeRoot, "usr", "lib64"), filepath.Join(runtimeRoot, "lib"), filepath.Join(runtimeRoot, "lib64")}
env := append([]string{}, base...)
oldPath := os.Getenv("PATH")
env = append(env, "PATH="+strings.Join(dirs, string(os.PathListSeparator))+string(os.PathListSeparator)+oldPath)
existingLD := os.Getenv("LD_LIBRARY_PATH")
oldPath := envValue(env, "PATH")
newPath := strings.Join(dirs, string(os.PathListSeparator))
if oldPath != "" {
newPath += string(os.PathListSeparator) + oldPath
}
env = setEnvValue(env, "PATH", newPath)
existingLD := envValue(env, "LD_LIBRARY_PATH")
ld := strings.Join(libdirs, string(os.PathListSeparator))
if existingLD != "" {
ld += string(os.PathListSeparator) + existingLD
}
env = append(env, "LD_LIBRARY_PATH="+ld)
env = setEnvValue(env, "LD_LIBRARY_PATH", ld)
return env, nil
}
func (a *App) syncWinetricks() (string, string, error) {
rel, err := githubLatest(winetricksRepo)
if err != nil {
return "", "", err
}
tag := rel.TagName
if tag == "" {
return "", "", errors.New("Winetricks-Release ohne Tag")
}
// Pin the exact Winetricks build used by this Citizen Launcher release.
// Gaming-stack updates should be deterministic: a future Winetricks release
// must first pass our regression tests before a Launcher release adopts it.
tag := winetricksVersion
dir := filepath.Join(a.vendorDir, "winetricks", tag)
target := filepath.Join(dir, "winetricks")
if _, err := os.Stat(target); err == nil {
if err := verifySHA256Hex(target, winetricksSHA256); err == nil {
_ = os.Chmod(target, 0o755)
return target, tag, nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", "", err
}
u := "https://raw.githubusercontent.com/Winetricks/winetricks/refs/tags/" + tag + "/src/winetricks"
if err := download(u, target+".new"); err != nil {
tmp := target + ".new"
_ = os.Remove(tmp)
if err := download(u, tmp); err != nil {
return "", "", err
}
if err := os.Chmod(target+".new", 0o755); err != nil {
if err := verifySHA256Hex(tmp, winetricksSHA256); err != nil {
_ = os.Remove(tmp)
return "", "", fmt.Errorf("Winetricks Integritätsprüfung: %w", err)
}
if err := os.Chmod(tmp, 0o755); err != nil {
_ = os.Remove(tmp)
return "", "", err
}
if err := os.Rename(target+".new", target); err != nil {
if err := os.Rename(tmp, target); err != nil {
return "", "", err
}
return target, tag, nil
@@ -579,16 +685,36 @@ func (a *App) backupIncompletePrefix(gc GameConfig) (GameConfig, string, error)
}
func (a *App) gameInstall() error {
unlock, err := a.acquireMaintenanceLock()
lock, err := a.stackOperationLock(2 * time.Second)
if err != nil {
return err
}
defer unlock()
defer releaseFileLock(lock)
return a.gameInstallUnlocked()
}
func (a *App) gameInstallUnlocked() error {
gc := a.loadGameConfig()
if err := validatePrefixPath(gc.Prefix); err != nil {
return err
}
if prefixInitialized(gc.Prefix) && prefixBusy(gc.Prefix) {
return errors.New("Der Star-Citizen-Wine-Prefix wird gerade verwendet. Bitte RSI Launcher, Wine-Konfiguration und Spiel schließen und erneut versuchen.")
}
hs := a.hardwareStatus(gc.Prefix)
if hs.State == "blocked" {
return fmt.Errorf("hardware nicht spielbereit: %s", hs.Reason)
}
if sys := a.systemReadiness(gc.Prefix); sys.State == "blocked" {
return errors.New(sys.Reason)
} else if sys.State == "prepare" {
if err := a.ensureSystemPrepared(); err != nil {
return fmt.Errorf("Systemvorbereitung: %w", err)
}
}
if sys := a.systemReadiness(gc.Prefix); sys.StorageFreeGiB > 0 && sys.StorageFreeGiB < 150 {
return fmt.Errorf("zu wenig freier Speicher: %d GiB; für eine Neuinstallation werden ungefähr 150 GiB empfohlen", sys.StorageFreeGiB)
}
// LUG is not part of the installation control path. A portable runtime is
// fetched lazily only when required system tools are missing.
@@ -618,6 +744,9 @@ func (a *App) gameInstall() error {
if err := a.ensurePrefixComponents(gc); err != nil {
return err
}
if err := a.syncDXVK(); err != nil {
return fmt.Errorf("DXVK: %w", err)
}
if err := a.ensureRSILauncher(gc, true); err != nil {
return err
}
@@ -627,9 +756,6 @@ func (a *App) gameInstall() error {
if err := a.writeLUGCompatibilityConfig(gc); err != nil {
a.logf("LUG compatibility config warning: %v", err)
}
if err := a.syncDXVK(); err != nil {
a.logf("DXVK post-install warning: %v", err)
}
gc.RSIInstaller = readText(filepath.Join(a.vendorDir, "rsi", "current.txt"))
gc.WinetricksVersion = readMetaVersion(filepath.Join(a.vendorDir, "winetricks", "meta.json"))
@@ -659,7 +785,9 @@ func (a *App) initializePrefix(gc GameConfig) error {
}
wait := exec.Command(filepath.Join(bin, "wineserver"), "-w")
wait.Env = env
_, _ = wait.CombinedOutput()
if waitOut, waitErr := wait.CombinedOutput(); waitErr != nil {
return fmt.Errorf("Wine-Prefix Abschluss: %s", formatCommandFailure(waitErr, waitOut))
}
deadline := time.Now().Add(30 * time.Second)
for !prefixInitialized(gc.Prefix) && time.Now().Before(deadline) {
time.Sleep(300 * time.Millisecond)
@@ -697,6 +825,13 @@ func (a *App) ensurePrefixComponents(gc GameConfig) error {
}
}
// RSI Launcher 2.x uses Windows PowerShell for installer support, game
// directory setup and verification. Avoid the fragile MSI path entirely:
// install a verified portable PowerShell Core plus the Wine wrapper.
if err := a.ensurePowerShell(gc, env); err != nil {
return fmt.Errorf("PowerShell-Kompatibilität: %w", err)
}
runner, _ := a.currentRunner()
wine := filepath.Join(runner, "bin", "wine")
// This is a convenience tweak only: prevent Wine from creating host file
@@ -708,10 +843,300 @@ func (a *App) ensurePrefixComponents(gc GameConfig) error {
if regErr != nil {
a.logf("non-fatal Wine registry association tweak warning: %s", formatCommandFailure(regErr, out))
}
// Let Wine finish registry writes before DXVK changes the same prefix.
wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w")
wait.Env = env
if waitOut, waitErr := wait.CombinedOutput(); waitErr != nil {
return fmt.Errorf("Wine-Prefix Abschluss: %s", formatCommandFailure(waitErr, waitOut))
}
_ = writeMeta(filepath.Join(a.vendorDir, "winetricks", "meta.json"), componentMeta{Version: tag, Updated: time.Now().Format(time.RFC3339)})
return nil
}
func (a *App) powerShellPaths(prefix string) (core, profile, wrapper64, wrapper32 string) {
coreDir := filepath.Join(prefix, "drive_c", "Program Files", "PowerShell", "7")
return filepath.Join(coreDir, "pwsh.exe"), filepath.Join(coreDir, "profile.ps1"),
filepath.Join(prefix, "drive_c", "windows", "system32", "WindowsPowerShell", "v1.0", "powershell.exe"),
filepath.Join(prefix, "drive_c", "windows", "syswow64", "WindowsPowerShell", "v1.0", "powershell.exe")
}
func (a *App) powerShellState(prefix string) string {
core, profile, wrapper64, wrapper32 := a.powerShellPaths(prefix)
for _, path := range []string{core, profile, wrapper64, wrapper32} {
fi, err := os.Stat(path)
if err != nil || fi.IsDir() || fi.Size() < 1024 {
return "repair"
}
}
marker := readText(filepath.Join(a.vendorDir, "powershell", "marker.txt"))
managed := powerShellCoreVersion + "+wrapper-" + powerShellWrapperVersion + "\n" + prefix
adopted := "verified\n" + prefix
if marker != managed && marker != adopted {
return "repair"
}
return "ready"
}
func (a *App) writePowerShellMarker(prefix, version string) error {
markerDir := filepath.Join(a.vendorDir, "powershell")
if err := os.MkdirAll(markerDir, 0o755); err != nil {
return err
}
return atomicWriteFile(filepath.Join(markerDir, "marker.txt"), []byte(version+"\n"+prefix+"\n"), 0o600)
}
func (a *App) probePowerShell(gc GameConfig, env []string) error {
_, _, wrapper64, _ := a.powerShellPaths(gc.Prefix)
runner, err := a.currentRunner()
if err != nil {
return err
}
wine := filepath.Join(runner, "bin", "wine")
probe := exec.Command(wine, wrapper64, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Write-Output CITIZEN_POWERSHELL_OK")
probe.Env = env
out, err := probe.CombinedOutput()
if err != nil || !strings.Contains(string(out), "CITIZEN_POWERSHELL_OK") {
return fmt.Errorf("PowerShell Selbsttest: %s", formatCommandFailure(err, out))
}
wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w")
wait.Env = env
if out, err := wait.CombinedOutput(); err != nil {
return fmt.Errorf("PowerShell Abschluss: %s", formatCommandFailure(err, out))
}
return nil
}
func verifySHA256Hex(path, want string) error {
got, err := fileHash(path)
if err != nil {
return err
}
if !strings.EqualFold(strings.TrimSpace(got), strings.TrimSpace(want)) {
return fmt.Errorf("SHA-256 mismatch for %s", filepath.Base(path))
}
return nil
}
func extractZipSafe(path, dir string) error {
r, err := zip.OpenReader(path)
if err != nil {
return err
}
defer r.Close()
cleanRoot, err := filepath.Abs(dir)
if err != nil {
return err
}
if err := os.MkdirAll(cleanRoot, 0o755); err != nil {
return err
}
for _, zf := range r.File {
name := filepath.Clean(filepath.FromSlash(zf.Name))
if name == "." || filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, ".."+string(os.PathSeparator)) {
return fmt.Errorf("unsicherer ZIP-Pfad: %q", zf.Name)
}
target := filepath.Join(cleanRoot, name)
rel, err := filepath.Rel(cleanRoot, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return fmt.Errorf("ZIP-Pfad verlässt Zielverzeichnis: %q", zf.Name)
}
if zf.FileInfo().Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("Symlink im ZIP wird nicht akzeptiert: %q", zf.Name)
}
if zf.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
rc, err := zf.Open()
if err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
rc.Close()
return err
}
_, copyErr := io.Copy(out, io.LimitReader(rc, 512<<20))
closeErr := out.Close()
rc.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
}
return nil
}
func (a *App) ensurePowerShell(gc GameConfig, env []string) error {
if a.powerShellState(gc.Prefix) == "ready" {
return nil
}
// Adopt a working existing Winetricks/manual installation instead of
// overwriting it merely because Citizen Launcher did not create it.
core, profile, wrapper64Existing, wrapper32Existing := a.powerShellPaths(gc.Prefix)
allPresent := true
for _, path := range []string{core, profile, wrapper64Existing, wrapper32Existing} {
fi, err := os.Stat(path)
if err != nil || fi.IsDir() || fi.Size() < 1024 {
allPresent = false
break
}
}
if allPresent {
if err := a.probePowerShell(gc, env); err == nil {
if err := a.writePowerShellMarker(gc.Prefix, "verified"); err != nil {
return err
}
a.logf("adopted existing working PowerShell compatibility layer")
return nil
}
}
cacheDir := filepath.Join(a.cacheDir, "powershell")
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return err
}
coreArchive := filepath.Join(cacheDir, "PowerShell-"+powerShellCoreVersion+"-win-x64.zip")
wrapperArchive := filepath.Join(cacheDir, "powershell-wrapper-"+powerShellWrapperVersion+".zip")
for _, asset := range []struct{ url, path, sha string }{
{powerShellCoreURL, coreArchive, powerShellCoreSHA256},
{powerShellWrapperURL, wrapperArchive, powerShellWrapperSHA256},
} {
if err := verifySHA256Hex(asset.path, asset.sha); err != nil {
_ = os.Remove(asset.path)
if err := download(asset.url, asset.path+".part"); err != nil {
_ = os.Remove(asset.path + ".part")
return err
}
if err := verifySHA256Hex(asset.path+".part", asset.sha); err != nil {
_ = os.Remove(asset.path + ".part")
return err
}
if err := os.Rename(asset.path+".part", asset.path); err != nil {
return err
}
}
}
stage, err := os.MkdirTemp(a.cacheDir, "powershell-stage-")
if err != nil {
return err
}
defer os.RemoveAll(stage)
coreStage := filepath.Join(stage, "core")
wrapperStage := filepath.Join(stage, "wrapper")
if err := extractZipSafe(coreArchive, coreStage); err != nil {
return fmt.Errorf("PowerShell Core entpacken: %w", err)
}
if err := extractZipSafe(wrapperArchive, wrapperStage); err != nil {
return fmt.Errorf("PowerShell Wrapper entpacken: %w", err)
}
if _, err := os.Stat(filepath.Join(coreStage, "pwsh.exe")); err != nil {
return errors.New("PowerShell Core ZIP enthält pwsh.exe nicht")
}
coreTarget := filepath.Join(gc.Prefix, "drive_c", "Program Files", "PowerShell", "7")
_, _, wrapper64Target, wrapper32Target := a.powerShellPaths(gc.Prefix)
managedTargets := []string{coreTarget, wrapper64Target, wrapper32Target}
backups := make(map[string]string, len(managedTargets))
for _, target := range managedTargets {
backup := target + ".citizen-backup"
_ = os.RemoveAll(backup)
if _, err := os.Lstat(target); err == nil {
if err := os.Rename(target, backup); err != nil {
for original, saved := range backups {
_ = os.Rename(saved, original)
}
return fmt.Errorf("vorhandene PowerShell-Datei sichern: %w", err)
}
backups[target] = backup
}
}
restore := true
defer func() {
if !restore {
return
}
for _, target := range managedTargets {
_ = os.RemoveAll(target)
}
for original, saved := range backups {
_ = os.Rename(saved, original)
}
}()
if err := copyTree(coreStage, coreTarget); err != nil {
return fmt.Errorf("PowerShell Core installieren: %w", err)
}
findWrapper := func(bits string) (string, error) {
candidates := []string{
filepath.Join(wrapperStage, bits, "powershell.exe"),
filepath.Join(wrapperStage, "powershell"+bits+".exe"),
}
for _, p := range candidates {
if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 1024 {
return p, nil
}
}
return "", fmt.Errorf("%s-bit PowerShell Wrapper fehlt", bits)
}
wrapper64, err := findWrapper("64")
if err != nil {
return err
}
wrapper32, err := findWrapper("32")
if err != nil {
return err
}
profileSource := filepath.Join(wrapperStage, "profile.ps1")
if _, err := os.Stat(profileSource); err != nil {
return errors.New("PowerShell Wrapper profile.ps1 fehlt")
}
for _, pair := range [][2]string{{wrapper64, wrapper64Target}, {wrapper32, wrapper32Target}, {profileSource, filepath.Join(coreTarget, "profile.ps1")}} {
if err := os.MkdirAll(filepath.Dir(pair[1]), 0o755); err != nil {
return err
}
if err := copyFile(pair[0], pair[1], 0o644); err != nil {
return err
}
}
runner, err := a.currentRunner()
if err != nil {
return err
}
wine := filepath.Join(runner, "bin", "wine")
reg := exec.Command(wine, "reg", "add", `HKEY_CURRENT_USER\Software\Wine\DllOverrides`, "/v", "powershell.exe", "/t", "REG_SZ", "/d", "native,builtin", "/f")
reg.Env = env
if out, err := reg.CombinedOutput(); err != nil {
return fmt.Errorf("PowerShell DLL-Override: %s", formatCommandFailure(err, out))
}
wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w")
wait.Env = env
if out, err := wait.CombinedOutput(); err != nil {
return fmt.Errorf("PowerShell Registry Abschluss: %s", formatCommandFailure(err, out))
}
if err := a.probePowerShell(gc, env); err != nil {
return err
}
if err := a.writePowerShellMarker(gc.Prefix, powerShellCoreVersion+"+wrapper-"+powerShellWrapperVersion); err != nil {
return err
}
restore = false
for _, saved := range backups {
_ = os.RemoveAll(saved)
}
a.logf("PowerShell compatibility ready core=%s wrapper=%s", powerShellCoreVersion, powerShellWrapperVersion)
return nil
}
func (a *App) ensureRSILauncher(gc GameConfig, forceIfMissing bool) error {
file, u, sha, err := a.latestRSIInstaller()
if err != nil {
@@ -775,26 +1200,37 @@ func (a *App) writeOwnedLaunchFiles(gc GameConfig) error {
if err := os.MkdirAll(binDir, 0o755); err != nil {
return err
}
stable := a.stableExecutable()
script := filepath.Join(binDir, "star-citizen-launch")
backend := filepath.Join(a.libDir, "citizen-launcher")
content := "#!/usr/bin/env bash\nexec " + shellQuote(backend) + " game-launch\n"
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
content := "#!/usr/bin/env bash\nexec " + shellQuote(stable) + " game-launch \"$@\"\n"
if err := atomicWriteFile(script, []byte(content), 0o755); err != nil {
return err
}
apps := filepath.Join(envOr("XDG_DATA_HOME", filepath.Join(a.home, ".local", "share")), "applications")
_ = os.MkdirAll(apps, 0o755)
desktop := `[Desktop Entry]
Name=Star Citizen
Comment=Star Citizen via Citizen Launcher
Type=Application
Categories=Game;
Terminal=false
StartupNotify=true
StartupWMClass=rsi launcher.exe
Exec=` + script + `\n`
if err := os.WriteFile(filepath.Join(apps, "citizen-launcher-star-citizen.desktop"), []byte(desktop), 0o644); err != nil {
apps := a.userApplicationsDir()
if err := os.MkdirAll(apps, 0o755); err != nil {
return err
}
desktop := "[Desktop Entry]\n" +
"Name=Star Citizen\n" +
"Comment=Star Citizen via Citizen Launcher\n" +
"Type=Application\n" +
"Categories=Game;\n" +
"Terminal=false\n" +
"StartupNotify=true\n" +
"StartupWMClass=RSI Launcher.exe\n" +
"Icon=citizen-launcher\n" +
"TryExec=" + stable + "\n" +
"Exec=" + desktopExecQuote(stable) + " game-launch\n"
if strings.Contains(desktop, `\\n`) {
return errors.New("internal desktop-entry newline error")
}
if err := atomicWriteFile(a.starDesktopPath(), []byte(desktop), 0o644); err != nil {
return err
}
if commandExists("update-desktop-database") {
_ = exec.Command("update-desktop-database", apps).Run()
}
return nil
}
@@ -814,28 +1250,68 @@ func (a *App) writeLUGCompatibilityConfig(gc GameConfig) error {
func (a *App) gameLaunch() error {
gc := a.loadGameConfig()
if err := validatePrefixPath(gc.Prefix); err != nil {
return err
}
state := a.prefixProcessState(gc.Prefix)
if state.Game {
return ErrGameAlreadyRunning
}
if state.RSI {
return ErrLauncherAlreadyRunning
}
lock, err := a.stackOperationLock(8 * time.Second)
if err != nil {
return err
}
defer releaseFileLock(lock)
state = a.prefixProcessState(gc.Prefix)
if state.Game {
return ErrGameAlreadyRunning
}
if state.RSI {
return ErrLauncherAlreadyRunning
}
if prefixBusy(gc.Prefix) {
return errors.New("Der Wine-Prefix wird bereits von einem anderen Prozess verwendet. Bitte laufende Wine-Werkzeuge schließen und erneut versuchen.")
}
st := a.gameStatus()
// On non-systemd distributions (or a stopped user timer), maintenance still
// happens opportunistically when the player launches the game. Never block
// the launch on network/update work.
triggerMaintenance := false
if cfg := a.loadConfig(); cfg.AutoMaintain {
stale := true
triggerMaintenance = true
if t, err := time.Parse(time.RFC3339, cfg.LastMaintenance); err == nil {
stale = time.Since(t) > 6*time.Hour
}
if stale && a.selfPath != "" {
_ = exec.Command(a.selfPath, "maintain").Start()
triggerMaintenance = time.Since(t) > 6*time.Hour
}
}
if st.Hardware == "blocked" {
return errors.New(st.HardwareReason)
}
if st.System.State == "blocked" {
return errors.New(st.System.Reason)
}
if st.System.State == "prepare" {
if err := a.ensureSystemPrepared(); err != nil {
return fmt.Errorf("Systemvorbereitung: %w", err)
}
}
if err := setLaunchNoFile(); err != nil {
a.logf("launch nofile warning: %v", err)
}
if !prefixInitialized(gc.Prefix) {
return errors.New("Wine-Prefix fehlt; zuerst Setup ausführen")
}
if _, err := os.Stat(gc.LauncherEXE); err != nil {
return errors.New("RSI Launcher fehlt; Reparatur ausführen")
}
if st.DXVKState != "ready" {
return errors.New("DXVK ist nicht vollständig aktiviert; bitte Automatisch reparieren ausführen")
}
if st.PowerShellState != "ready" {
return errors.New("Die RSI-PowerShell-Kompatibilität ist unvollständig; bitte Automatisch reparieren ausführen")
}
env, runner, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
@@ -844,39 +1320,102 @@ func (a *App) gameLaunch() error {
"__GL_SHADER_DISK_CACHE=1",
"__GL_SHADER_DISK_CACHE_SIZE=10737418240",
"__GL_SHADER_DISK_CACHE_PATH="+gc.Prefix,
"__GL_SHADER_DISK_CACHE_SKIP_CLEANUP=1",
"MESA_SHADER_CACHE_DIR="+gc.Prefix,
"MESA_SHADER_CACHE_MAX_SIZE=10G",
)
// A running game/launcher was ruled out above. Clearing the dedicated
// wineserver here only removes stale prefix processes from previous crashes.
ws := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-k")
ws.Env = env
_, _ = ws.CombinedOutput()
log := filepath.Join(a.stateDir, "rsi-launcher.log")
_ = os.MkdirAll(a.stateDir, 0o755)
rotateFile(log, 8*1024*1024, 4*1024*1024)
f, err := os.OpenFile(log, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
cmd := exec.Command(filepath.Join(runner, "bin", "wine"), gc.LauncherEXE)
cmd.Env = env
cmd.Dir = filepath.Dir(gc.LauncherEXE)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Stdout = f
cmd.Stderr = f
if err := cmd.Start(); err != nil {
f.Close()
_ = f.Close()
return err
}
_ = f.Close()
a.logf("RSI Launcher started pid=%d", cmd.Process.Pid)
return nil
waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
// Wine may hand off to the Windows child and let the wrapper process exit.
// Treat the launch as successful when the actual RSI process appears.
deadline := time.Now().Add(10 * time.Second)
var wrapperErr error
for time.Now().Before(deadline) {
if a.rsiLauncherRunning(gc.Prefix) {
a.logf("RSI Launcher started pid=%d", cmd.Process.Pid)
if triggerMaintenance && !detectPlatform().SystemdUser {
stable := a.stableExecutable()
go func() {
time.Sleep(1500 * time.Millisecond)
_ = exec.Command(stable, "maintain").Start()
}()
}
return nil
}
select {
case wrapperErr = <-waitCh:
if !a.rsiLauncherRunning(gc.Prefix) {
if wrapperErr != nil {
return fmt.Errorf("RSI Launcher wurde früh beendet: %v. Details: %s", wrapperErr, log)
}
return fmt.Errorf("RSI Launcher wurde beendet, bevor sein Prozess sichtbar wurde. Details: %s", log)
}
default:
}
time.Sleep(250 * time.Millisecond)
}
if a.rsiLauncherRunning(gc.Prefix) {
return nil
}
return fmt.Errorf("RSI Launcher wurde gestartet, hat aber innerhalb von 10 Sekunden keinen laufenden Launcher-Prozess erzeugt. Details: %s", log)
}
func (a *App) gameRepair() error {
lock, err := a.stackOperationLock(2 * time.Second)
if err != nil {
return err
}
defer releaseFileLock(lock)
gc := a.loadGameConfig()
if err := validatePrefixPath(gc.Prefix); err != nil {
return err
}
if prefixInitialized(gc.Prefix) && prefixBusy(gc.Prefix) {
return errors.New("Reparatur pausiert: RSI Launcher, Star Citizen oder ein anderes Wine-Werkzeug verwendet den Prefix noch.")
}
hs := a.hardwareStatus(gc.Prefix)
if hs.State == "blocked" {
return errors.New(hs.Reason)
}
if sys := a.systemReadiness(gc.Prefix); sys.State == "blocked" {
return errors.New(sys.Reason)
} else if sys.State == "prepare" {
if err := a.ensureSystemPrepared(); err != nil {
return err
}
}
if err := a.syncWineRunner(); err != nil {
return err
}
if !prefixInitialized(gc.Prefix) {
return a.gameInstall()
return a.gameInstallUnlocked()
}
if err := a.ensurePrefixComponents(gc); err != nil {
return err
+116 -2
View File
@@ -1,6 +1,9 @@
package main
import (
"archive/zip"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -49,10 +52,10 @@ func TestParseRSILatestVersionFallback(t *testing.T) {
}
}
func TestBasePrefixDoesNotRequirePowerShell(t *testing.T) {
func TestBasePrefixDoesNotUseFragilePowerShellMSI(t *testing.T) {
for _, v := range basePrefixWinetricksVerbs {
if strings.EqualFold(v, "powershell") {
t.Fatalf("PowerShell must remain optional")
t.Fatalf("PowerShell must be managed by the verified portable compatibility layer, not Winetricks/MSI")
}
}
}
@@ -65,3 +68,114 @@ func TestWineRegistryKeyUsesSingleSeparators(t *testing.T) {
t.Fatalf("unexpected registry key: %q", wineFileAssociationsKey)
}
}
func TestPowerShellStateRequiresFilesAndMarker(t *testing.T) {
a := testApp(t)
prefix := filepath.Join(a.home, "Games", "star-citizen")
core, profile, wrapper64, wrapper32 := a.powerShellPaths(prefix)
for _, p := range []string{core, profile, wrapper64, wrapper32} {
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, make([]byte, 2048), 0o644); err != nil {
t.Fatal(err)
}
}
if got := a.powerShellState(prefix); got != "repair" {
t.Fatalf("without verification marker got %q", got)
}
if err := a.writePowerShellMarker(prefix, powerShellCoreVersion+"+wrapper-"+powerShellWrapperVersion); err != nil {
t.Fatal(err)
}
if got := a.powerShellState(prefix); got != "ready" {
t.Fatalf("managed compatibility layer got %q", got)
}
if err := os.Remove(wrapper64); err != nil {
t.Fatal(err)
}
if got := a.powerShellState(prefix); got != "repair" {
t.Fatalf("missing wrapper got %q", got)
}
}
func TestPowerShellPinnedArtifactsUseSHA256(t *testing.T) {
for name, sha := range map[string]string{
"PowerShell Core": powerShellCoreSHA256,
"PowerShell wrapper": powerShellWrapperSHA256,
} {
if len(sha) != 64 {
t.Fatalf("%s SHA-256 length=%d", name, len(sha))
}
for _, c := range sha {
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
t.Fatalf("%s contains non-hex SHA-256 %q", name, sha)
}
}
}
if !strings.Contains(powerShellCoreURL, powerShellCoreVersion) || !strings.Contains(powerShellWrapperURL, powerShellWrapperVersion) {
t.Fatal("pinned PowerShell URLs do not match pinned versions")
}
if powerShellCoreSHA256 != "cd62ad6d8174cc6fb85b335a0058444bc934fe27c39fa97fe342134286d28af9" {
t.Fatal("PowerShell Core checksum drifted from upstream v7.4.19 release")
}
if powerShellWrapperSHA256 != "08f866265e0395f4bc5ddb18f3dff345771d039a62911e506c62084fd2533ec3" {
t.Fatal("PowerShell wrapper checksum drifted from upstream v3.0.5 release")
}
}
func TestExtractZipSafeRejectsTraversalAndSymlink(t *testing.T) {
makeZip := func(name string, mode os.FileMode) string {
t.Helper()
path := filepath.Join(t.TempDir(), "test.zip")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
h := &zip.FileHeader{Name: name, Method: zip.Store}
h.SetMode(mode)
w, err := zw.CreateHeader(h)
if err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte("payload"))
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
return path
}
if err := extractZipSafe(makeZip("../escape", 0o644), t.TempDir()); err == nil {
t.Fatal("ZIP traversal was accepted")
}
if err := extractZipSafe(makeZip("link", os.ModeSymlink|0o777), t.TempDir()); err == nil {
t.Fatal("ZIP symlink was accepted")
}
}
func TestFormatCommandFailureHandlesMissingExpectedOutput(t *testing.T) {
if got := formatCommandFailure(nil, nil); got == "" {
t.Fatal("nil error and empty output should still yield a diagnostic")
}
if got := formatCommandFailure(nil, []byte("unexpected output")); got != "unexpected output" {
t.Fatalf("unexpected diagnostic %q", got)
}
}
func TestRequiredReleaseDigestFailsClosed(t *testing.T) {
p := filepath.Join(t.TempDir(), "asset")
if err := os.WriteFile(p, []byte("abc"), 0o600); err != nil {
t.Fatal(err)
}
if err := requireReleaseDigest(p, ""); err == nil {
t.Fatal("missing digest was accepted")
}
if err := requireReleaseDigest(p, "md5:deadbeef"); err == nil {
t.Fatal("non-SHA256 digest was accepted")
}
if err := requireReleaseDigest(p, "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); err != nil {
t.Fatalf("known SHA-256 rejected: %v", err)
}
}
+87 -22
View File
@@ -11,6 +11,7 @@ import (
"os/user"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
@@ -32,7 +33,16 @@ func (a *App) createSupportBundle() (string, error) {
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
closeAll := func() { _ = tw.Close(); _ = gz.Close(); _ = f.Close() }
closed := false
closeAll := func() {
if closed {
return
}
closed = true
_ = tw.Close()
_ = gz.Close()
_ = f.Close()
}
defer closeAll()
add := func(name string, data []byte) error {
@@ -49,22 +59,29 @@ func (a *App) createSupportBundle() (string, error) {
b = append(b, '\n')
_ = add(name, b)
}
gc := a.loadGameConfig()
addJSON("platform.json", detectPlatform())
addJSON("game-status.json", a.gameStatus())
if st, _ := a.status(false); true {
addJSON("launcher-status.json", st)
}
addJSON("game-config.json", a.loadGameConfig())
_ = add("README.txt", []byte("Citizen Launcher support bundle\nID: "+id+"\nPersonal paths, email/IP/MAC and common tokens are redacted on a best-effort basis.\n"))
st, _ := a.status(false)
addJSON("launcher-status.json", st)
addJSON("self-update.json", func() SelfUpdateStatus { s, _ := a.selfUpdateStatus(false); return s }())
addJSON("game-config.json", gc)
addJSON("system-readiness.json", a.systemReadiness(gc.Prefix))
_ = add("README.txt", []byte("Citizen Launcher support bundle\nID: "+id+"\nPersonal paths, email/IP/MAC and common auth/token values are redacted on a best-effort basis. Review before sharing if desired.\n"))
for name, cmd := range map[string][]string{
"uname.txt": {"uname", "-a"},
"os-release.txt": {"cat", "/etc/os-release"},
"memory.txt": {"free", "-h"},
"disk.txt": {"df", "-hT"},
"pci.txt": {"lspci", "-nnk"},
"vulkan.txt": {"vulkaninfo", "--summary"},
"systemd-timer.txt": {"systemctl", "--user", "status", "citizen-launcher-maintenance.timer", "--no-pager"},
"uname.txt": {"uname", "-a"},
"os-release.txt": {"cat", "/etc/os-release"},
"memory.txt": {"free", "-h"},
"swap.txt": {"swapon", "--show"},
"disk.txt": {"df", "-hT"},
"mount.txt": {"findmnt", "-T", gc.Prefix, "-o", "TARGET,FSTYPE,OPTIONS"},
"pci.txt": {"lspci", "-nnk"},
"vulkan.txt": {"vulkaninfo", "--summary"},
"limits.txt": {"sh", "-c", "printf 'vm.max_map_count='; cat /proc/sys/vm/max_map_count; printf 'nofile='; ulimit -Sn; printf 'nofile-hard='; ulimit -Hn"},
"maintenance-timer.txt": {"systemctl", "--user", "status", "citizen-launcher-maintenance.timer", "--no-pager"},
"package-update-timer.txt": {"systemctl", "status", "citizen-launcher-self-update.timer", "--no-pager"},
} {
if _, err := exec.LookPath(cmd[0]); err != nil {
continue
@@ -72,17 +89,63 @@ func (a *App) createSupportBundle() (string, error) {
out, _ := exec.Command(cmd[0], cmd[1:]...).CombinedOutput()
_ = add(name, out)
}
for name, path := range map[string]string{
"launcher.log": a.logPath,
"rsi-launcher.log": filepath.Join(a.stateDir, "rsi-launcher.log"),
"launcher.log": a.logPath,
"rsi-wine.log": filepath.Join(a.stateDir, "rsi-launcher.log"),
} {
if b, err := tailFile(path, 700000); err == nil {
if b, err := tailFile(path, 900000); err == nil {
_ = add(name, b)
}
}
// Product-owned desktop integration is useful for diagnosing launch failures.
for name, path := range map[string]string{
"desktop-citizen-launcher.txt": a.mainDesktopPath(),
"desktop-star-citizen.txt": a.starDesktopPath(),
"launch-wrapper.txt": filepath.Join(a.dataDir, "bin", "star-citizen-launch"),
} {
if b, err := os.ReadFile(path); err == nil {
_ = add(name, b)
}
}
// Current RSI launcher / game / EAC logs, capped and sanitized.
for name, path := range a.gameLogCandidates(gc) {
if b, err := tailFile(path, 1200000); err == nil {
_ = add(name, b)
}
}
closeAll()
return target, nil
}
func (a *App) gameLogCandidates(gc GameConfig) map[string]string {
out := map[string]string{}
usersRoot := filepath.Join(gc.Prefix, "drive_c", "users")
users, _ := os.ReadDir(usersRoot)
for _, u := range users {
if !u.IsDir() || strings.EqualFold(u.Name(), "Public") {
continue
}
roam := filepath.Join(usersRoot, u.Name(), "AppData", "Roaming")
if _, err := os.Stat(filepath.Join(roam, "rsilauncher", "logs", "log.log")); err == nil {
out["rsi-internal.log"] = filepath.Join(roam, "rsilauncher", "logs", "log.log")
}
matches, _ := filepath.Glob(filepath.Join(roam, "EasyAntiCheat", "*", "*", "anticheatlauncher.log"))
sort.Strings(matches)
if len(matches) > 0 {
out["eac.log"] = matches[len(matches)-1]
}
}
gameLog := filepath.Join(gc.GameDir, "LIVE", "Game.log")
if _, err := os.Stat(gameLog); err == nil {
out["game.log"] = gameLog
}
return out
}
func tailFile(path string, max int64) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
@@ -111,17 +174,19 @@ func sanitizeSupport(in []byte, home string) []byte {
s = strings.ReplaceAll(s, home, "~")
}
if u, err := user.Current(); err == nil && u.Username != "" {
s = regexp.MustCompile(`(?i)\\b`+regexp.QuoteMeta(u.Username)+`\\b`).ReplaceAllString(s, "<user>")
s = regexp.MustCompile(`(?i)\b`+regexp.QuoteMeta(u.Username)+`\b`).ReplaceAllString(s, "<user>")
}
if h, _ := os.Hostname(); h != "" {
s = strings.ReplaceAll(s, h, "<host>")
}
patterns := []struct{ re, repl string }{
{`(?i)\\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}\\b`, "<email>"},
{`\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b`, "<ip>"},
{`(?i)\\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\\b`, "<mac>"},
{`(?i)Bearer\\s+[A-Za-z0-9._~+/=-]+`, "Bearer <redacted>"},
{`(?i)(token|secret|password|passwd|cookie|session)\\s*[:=]\\s*[^\\s,;]+`, "$1=<redacted>"},
{`(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b`, "<email>"},
{`\b(?:\d{1,3}\.){3}\d{1,3}\b`, "<ip>"},
{`(?i)\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b`, "<mac>"},
{`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+`, "Bearer <redacted>"},
{`\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`, "<jwt>"},
{`(?i)(authorization|token|access_token|refresh_token|secret|password|passwd|cookie|session)\s*[:=]\s*[^\s,;]+`, "$1=<redacted>"},
{`(?i)([?&](?:token|access_token|refresh_token|session|auth|key|secret)=)[^&#\s]+`, "$1<redacted>"},
}
for _, p := range patterns {
s = regexp.MustCompile(p.re).ReplaceAllString(s, p.repl)
+12 -9
View File
@@ -1,11 +1,14 @@
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()}
const B=window.CITIZEN_BASE;let current=null,activeJob=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()).trim());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||'—';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==='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);
function primaryFor(h){if(h==='ready')return['STAR CITIZEN STARTEN','launch'];if(h==='install-game')return['RSI LAUNCHER ÖFFNEN','launch'];if(h==='hardware-blocked')return['SYSTEM-DETAILS','support'];if(h==='system-prepare')return['SYSTEM AUTOMATISCH VORBEREITEN','prepare-system'];if(h==='repair'||h==='launcher-repair')return['AUTOMATISCH REPARIEREN','repair'];return['EINRICHTEN & STARTKLAR MACHEN','setup']}
function setStep(name,state){const e=document.querySelector(`[data-step="${name}"]`);if(!e)return;e.dataset.state=state}
function renderSteps(g){setStep('system',g.hardware==='blocked'||g.system?.state==='blocked'?'bad':g.system?.state==='prepare'?'warn':'good');setStep('wine',g.wine_version&&g.powershell_state==='ready'?'good':g.wine_version?'warn':'todo');setStep('launcher',g.launcher_state==='ready'?'good':'todo');setStep('game',g.game_state==='ready'?'good':g.game_state==='partial'?'warn':'todo')}
function setButtonsBusy(busy){document.querySelectorAll('button[data-action]').forEach(b=>{if(b.dataset.action!=='restart')b.disabled=busy});$('primary').disabled=busy}
async function refresh(){try{const s=await api('/api/status');current=s;activeJob=s.active_job||null;$('version').textContent='v'+s.version;$('platform').textContent=[s.platform.name,s.platform.desktop].filter(Boolean).join(' · ');$('distro').textContent=s.platform.name;$('session').textContent=s.platform.session||'—';txt('packageUpdates',s.package_auto_update?'automatisch':'manuell',s.package_auto_update?'good':'warn');const ub=$('updateBanner');if(s.restart_required){ub.classList.remove('hidden');$('updateText').textContent='Installiert ist '+(s.installed_version||'eine neuere Version')+', dieses Fenster läuft noch mit '+s.version+'.'}else ub.classList.add('hidden');const g=s.game;$('hero').dataset.health=g.health||'setup';let head='READY FOR SETUP',sub='Citizen Launcher übernimmt Wine, DXVK, Prefix und RSI Launcher.';if(g.health==='ready'){head='FLIGHT READY';sub='Star Citizen ist startbereit. Autopilot hält den Gaming-Stack aktuell.'}else if(g.health==='install-game'){head='RSI LAUNCHER READY';sub='Melde dich im RSI Launcher an und installiere Star Citizen am vorgegebenen C:-Pfad.'}else if(g.health==='hardware-blocked'){head='SYSTEM NICHT SPIELBEREIT';sub=g.hardware_reason||g.system?.reason||'Eine Voraussetzung ist noch nicht erfüllt.'}else if(g.health==='system-prepare'){head='SYSTEMVORBEREITUNG';sub='Citizen Launcher kann die benötigten Linux-Limits automatisch setzen.'}else if(g.health==='repair'||g.health==='launcher-repair'){head='REPAIR AVAILABLE';sub='Ein reparierbarer Zustand wurde erkannt. Spieldaten werden nicht gelöscht.'}$('headline').textContent=head;$('subline').textContent=sub;const [label,act]=primaryFor(g.health);$('primary').textContent=label;$('primary').dataset.action=act;renderSteps(g);txt('gpu',g.gpu||'nicht eindeutig erkannt',g.hardware==='blocked'?'bad':'good');txt('vulkan',g.vulkan,g.vulkan==='blocked'?'bad':g.vulkan==='ready'?'good':'warn');txt('memory',`${g.ram_gib} GiB RAM · ${g.combined_gib} GiB gesamt`,g.combined_gib>=48?'good':'warn');txt('disk',(g.system?.storage_free_gib??g.disk_free_gib)+' GiB',((g.system?.storage_free_gib??g.disk_free_gib)>=150||g.game_state==='ready')?'good':'warn');txt('filesystem',g.system?.filesystem||'unbekannt',g.system?.filesystem_ok===false?'bad':'good');txt('wine',g.wine_version||'wird eingerichtet',g.wine_version?'good':'warn');txt('dxvk',g.dxvk_state==='ready'?(g.dxvk_version||'bereit'):g.dxvk_state==='repair'?((g.dxvk_version||'DXVK')+' · Reparatur nötig'):'wird eingerichtet',g.dxvk_state==='ready'?'good':g.dxvk_state==='repair'?'warn':'warn');txt('powershell',g.powershell_state==='ready'?'bereit':g.prefix_state==='ready'?'Reparatur nötig':'wird eingerichtet',g.powershell_state==='ready'?'good':g.prefix_state==='ready'?'warn':'');txt('rsi',g.launcher_state==='ready'?'bereit':'Setup',g.launcher_state==='ready'?'good':'warn');txt('game',g.game_state==='ready'?'LIVE bereit':g.game_state==='partial'?'teilweise vorhanden':'noch nicht installiert',g.game_state==='ready'?'good':g.game_state==='partial'?'warn':'');txt('prefix',g.prefix||'—');txt('autopilot',g.autopilot?'aktiv':'aus',g.autopilot?'good':'warn');$('systemPill').textContent=g.hardware==='blocked'||g.system?.state==='blocked'?'BLOCKED':g.system?.state==='prepare'?'SETUP':'READY';$('systemPill').className='pill '+(g.hardware==='blocked'||g.system?.state==='blocked'?'bad':g.system?.state==='prepare'?'warn':'good');const stackReady=g.launcher_state==='ready'&&g.dxvk_state==='ready'&&g.powershell_state==='ready';$('stackPill').textContent=stackReady?'READY':'SETUP';$('stackPill').className='pill '+(stackReady?'good':'warn');$('autoPill').textContent=g.autopilot?'AUTO':'OFF';$('autoPill').className='pill '+(g.autopilot?'good':'warn');$('raw').textContent=JSON.stringify(s,null,2);setButtonsBusy(!!activeJob);if(activeJob&&$('job').classList.contains('hidden')){showJob('running',friendlyAction(activeJob.action),'Wird ausgeführt …');poll(activeJob.id)}}catch(e){$('headline').textContent='BACKEND ERROR';$('subline').textContent=e.message}}
function friendlyAction(a){return({setup:'Einrichtung',repair:'Automatische Reparatur',maintain:'Gaming-Stack aktualisieren',doctor:'Wine-Selbsttest',support:'Support-Paket', 'prepare-system':'Systemvorbereitung','choose-prefix':'Installationsordner'})[a]||'Aktion'}
function showJob(state,title,message,raw=''){const box=$('job');box.classList.remove('hidden','error','done');if(state==='error')box.classList.add('error');if(state==='done')box.classList.add('done');$('jobTitle').textContent=title;$('jobText').textContent=message||'';const spin=box.querySelector('.spinner'),symbol=$('jobSymbol');spin.classList.toggle('hidden',state!=='running');symbol.classList.toggle('hidden',state==='running');symbol.textContent=state==='done'?'✓':'!';const details=$('jobDetails');if(raw&&raw!==message){details.classList.remove('hidden');$('jobRaw').textContent=raw}else{details.classList.add('hidden');$('jobRaw').textContent=''}}
async function action(name){try{if(name==='restart'){await api('/api/action/restart',{method:'POST'});showJob('done','Launcher wird neu gestartet','Die neue Version wird geöffnet …');return}if(name==='launch'){const r=await api('/api/action/launch',{method:'POST'});if(r.state==='game-running'){showJob('done','Star Citizen läuft bereits','Es wurde keine zweite Instanz gestartet.')}else if(r.state==='launcher-running'){showJob('done','RSI Launcher läuft bereits','Es wurde keine zweite Instanz gestartet.')}else{showJob('done','RSI Launcher gestartet','Der Launcher wurde gestartet.')}return}const j=await api('/api/action/'+name,{method:'POST'});activeJob=j;setButtonsBusy(true);showJob('running',friendlyAction(name),'Wird ausgeführt …');poll(j.id)}catch(e){setButtonsBusy(false);showJob('error','Aktion konnte nicht gestartet werden',e.message,e.message)}}
async function poll(id){try{const j=await api('/api/job/'+id);if(j.state==='running'){setTimeout(()=>poll(id),900);return}activeJob=null;setButtonsBusy(false);if(j.state==='done'){let m=j.message||'Die Aktion wurde erfolgreich abgeschlossen.';if(j.action==='choose-prefix'&&j.message)m='Installationsort: '+j.message;showJob('done','Abgeschlossen',m)}else showJob('error','Aktion fehlgeschlagen',j.message||'Die Aktion konnte nicht abgeschlossen werden.',j.error||'');await refresh()}catch(e){activeJob=null;setButtonsBusy(false);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||button.disabled)return;const a=button.dataset.action;if(a)action(a);const o=button.dataset.open;if(o)api('/api/open/'+o,{method:'POST'}).catch(err=>showJob('error','Öffnen fehlgeschlagen',err.message,err.message))});$('toggle').onclick=()=>$('advanced').classList.toggle('hidden');refresh();setInterval(refresh,10000);
+9 -9
View File
@@ -2,16 +2,16 @@
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Citizen Launcher</title><link rel="stylesheet" href="__BASE__/assets/style.css"></head>
<body><div class="scan"></div><main>
<header><div class="mark">✦</div><div class="brand"><div class="eyebrow">LINUX FLIGHT SYSTEM</div><h1>Citizen Launcher</h1><p id="platform">Linux wird erkannt …</p></div><div class="version" id="version"></div></header>
<header><div class="mark">✦</div><div class="brand"><div class="eyebrow">CITIZEN FLIGHT SYSTEM</div><h1>Citizen Launcher</h1><p id="platform">Linux wird erkannt …</p></div><div class="version" id="version"></div></header>
<section class="update-banner hidden" id="updateBanner"><div><b>Neue Version installiert</b><p id="updateText">Citizen Launcher wurde im Hintergrund aktualisiert.</p></div><button data-action="restart">LAUNCHER NEU STARTEN</button></section>
<section class="hero" id="hero"><div class="hero-copy"><div class="eyebrow">SYSTEM STATE</div><h2 id="headline">SYSTEM CHECK</h2><p id="subline">Hardware und Gaming-Stack werden geprüft.</p></div><button class="primary" id="primary">PRÜFEN …</button></section>
<section class="hero" id="hero"><div class="hero-copy"><div class="eyebrow">FLIGHT STATUS</div><h2 id="headline">SYSTEM CHECK</h2><p id="subline">Hardware und Gaming-Stack werden geprüft.</p><div class="steps" id="steps"><span data-step="system">SYSTEM</span><i></i><span data-step="wine">WINE</span><i></i><span data-step="launcher">RSI</span><i></i><span data-step="game">GAME</span></div></div><button class="primary" id="primary">PRÜFEN …</button></section>
<section class="dashboard">
<article class="card hardware"><h3>Hardware</h3><div class="metric"><span>GPU</span><b id="gpu">—</b></div><div class="metric"><span>Vulkan</span><b id="vulkan">—</b></div><div class="metric"><span>RAM</span><b id="ram">—</b></div></article>
<article class="card"><h3>Gaming Stack</h3><div class="metric"><span>Wine</span><b id="wine">—</b></div><div class="metric"><span>DXVK</span><b id="dxvk">—</b></div><div class="metric"><span>Launcher</span><b id="rsi">—</b></div></article>
<article class="card"><h3>Autopilot</h3><div class="metric"><span>Gaming-Stack</span><b id="autopilot">—</b></div><div class="metric"><span>Launcher-Updates</span><b id="packageUpdates">—</b></div><div class="metric"><span>Distribution</span><b id="distro">—</b></div><div class="metric"><span>Session</span><b id="session">—</b></div></article>
<article class="card hardware"><div class="card-title"><h3>System</h3><span class="pill" id="systemPill">CHECK</span></div><div class="metric"><span>GPU</span><b id="gpu">—</b></div><div class="metric"><span>Vulkan</span><b id="vulkan">—</b></div><div class="metric"><span>RAM + Swap</span><b id="memory">—</b></div><div class="metric"><span>Speicher frei</span><b id="disk">—</b></div><div class="metric"><span>Dateisystem</span><b id="filesystem">—</b></div></article>
<article class="card"><div class="card-title"><h3>Gaming Stack</h3><span class="pill" id="stackPill">CHECK</span></div><div class="metric"><span>Wine</span><b id="wine">—</b></div><div class="metric"><span>DXVK</span><b id="dxvk">—</b></div><div class="metric"><span>RSI-Kompatibilität</span><b id="powershell">—</b></div><div class="metric"><span>RSI Launcher</span><b id="rsi">—</b></div><div class="metric"><span>Spieldateien</span><b id="game">—</b></div><div class="metric"><span>Installationsort</span><b id="prefix">—</b></div></article>
<article class="card"><div class="card-title"><h3>Autopilot</h3><span class="pill" id="autoPill">CHECK</span></div><div class="metric"><span>Gaming-Stack</span><b id="autopilot">—</b></div><div class="metric"><span>Launcher-Updates</span><b id="packageUpdates">—</b></div><div class="metric"><span>Distribution</span><b id="distro">—</b></div><div class="metric"><span>Session</span><b id="session">—</b></div></article>
</section>
<section class="actions"><button data-action="maintain"><strong>Stack aktualisieren</strong><small>Wine, DXVK & Launcher prüfen</small></button><button data-action="repair"><strong>Automatisch reparieren</strong><small>Setup und Launcher heilen</small></button><button data-action="doctor"><strong>Wine-Selbsttest</strong><small>Prefix isoliert prüfen</small></button><button data-action="support"><strong>Support-Paket</strong><small>Diagnose für Hilfe erstellen</small></button></section>
<section class="actions"><button data-action="repair"><strong>Automatisch reparieren</strong><small>Prefix, RSI-Kompatibilität, DXVK & Desktop-Starter</small></button><button data-action="maintain"><strong>Alles aktualisieren</strong><small>Kompatibles Wine, DXVK und RSI prüfen</small></button><button data-action="support"><strong>Support-Paket</strong><small>Logs und Diagnose datensparsam sammeln</small></button></section>
<section class="job hidden" id="job"><div class="job-icon"><div class="spinner"></div><span class="job-symbol hidden" id="jobSymbol">!</span></div><div class="job-body"><b id="jobTitle">Aktion läuft</b><p id="jobText"></p><details class="job-details hidden" id="jobDetails"><summary>Technische Details</summary><pre id="jobRaw"></pre></details></div></section>
<section class="details"><button id="toggle">Erweiterte Informationen</button><div id="advanced" class="hidden"><pre id="raw"></pre><div class="small-actions"><button data-open="logs">Logs öffnen</button><button data-open="downloads">Downloads öffnen</button></div></div></section>
<footer>Citizen Launcher verwaltet den Gaming-Stack im Benutzerkonto. System- und GPU-Treiber bleiben bei deiner Distribution.</footer>
</main><script>window.CITIZEN_BASE="__BASE__";</script><script src="__BASE__/assets/app.js"></script></body></html>
<section class="details"><button id="toggle">Erweiterte Optionen</button><div id="advanced" class="hidden"><div class="advanced-actions"><button data-action="doctor">Wine-Selbsttest</button><button data-action="prepare-system">Linux-System vorbereiten</button><button data-action="choose-prefix">Installationsordner wählen</button><button data-open="logs">Logs öffnen</button><button data-open="downloads">Downloads öffnen</button></div><details><summary>Technischer Status</summary><pre id="raw"></pre></details></div></section>
<footer>Citizen Launcher 1.0 verwaltet Wine, DXVK, RSI-Kompatibilität, Launcher und Updates. Systemtreiber bleiben bei deiner Distribution.</footer>
</main><script>window.CITIZEN_BASE="__BASE__";</script><script src="__BASE__/assets/app.js"></script></body></html>
File diff suppressed because one or more lines are too long