v1.0.0
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ©
|
||||
}
|
||||
|
||||
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 ©
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user