This commit is contained in:
2026-08-31 18:46:57 +02:00
parent 21c588f17a
commit 20fcf46c29
34 changed files with 4971 additions and 210 deletions
+929
View File
@@ -0,0 +1,929 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
)
const (
lugRepo = "starcitizen-lug/lug-helper"
wineRepo = "starcitizen-lug/lug-wine"
dxvkRepo = "doitsujin/dxvk"
)
type githubRelease struct {
TagName string `json:"tag_name"`
Assets []githubAsset `json:"assets"`
}
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Size int64 `json:"size"`
Digest string `json:"digest,omitempty"`
}
type componentMeta struct {
Version string `json:"version"`
Asset string `json:"asset,omitempty"`
URL string `json:"url,omitempty"`
Updated string `json:"updated"`
}
type wineRejection struct {
Version string `json:"version"`
Reason string `json:"reason"`
Fingerprint string `json:"fingerprint"`
Updated string `json:"updated"`
}
func (a *App) enableAutopilot() error {
cfg := a.loadConfig()
cfg.AutoMaintain = true
// The optional Omarchy integration may also be eligible for plugin updates.
st, _ := a.status(true)
if st.Managed == "omarchy-git" && !st.Dirty && st.Remote != "" {
cfg.AutoApply = true
if cfg.TrustedRemote == "" {
cfg.TrustedRemote = st.Remote
}
}
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")
}
a.logf("autopilot enabled auto_integration=%v", cfg.AutoApply)
fmt.Println("enabled")
return nil
}
func (a *App) disableAutopilot() error {
cfg := a.loadConfig()
cfg.AutoMaintain = false
cfg.LastResult = "autopilot-disabled"
a.saveConfig(cfg)
a.logf("autopilot disabled")
fmt.Println("disabled")
return nil
}
func (a *App) maintainGamingStack() error {
unlock, err := a.acquireMaintenanceLock()
if err != nil {
return err
}
defer unlock()
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.
if err := a.syncWineRunner(); err != nil {
problems = append(problems, "Wine runner: "+err.Error())
}
if err := a.syncDXVK(); err != nil {
problems = append(problems, "DXVK: "+err.Error())
}
if err := a.syncRSILauncherIfNeeded(); err != nil {
problems = append(problems, "RSI Launcher: "+err.Error())
}
result := "ok"
if len(problems) > 0 {
result = strings.Join(problems, " | ")
}
a.updateConfig(func(c *Config) {
c.LastMaintenance = time.Now().Format(time.RFC3339)
c.MaintenanceResult = result
})
a.logf("maintenance end result=%s", result)
if len(problems) > 0 {
return errors.New(result)
}
return nil
}
func (a *App) syncLUGHelper() error {
release, err := githubLatest(lugRepo)
if err != nil {
return err
}
asset, err := selectAsset(release.Assets, func(n string) bool {
n = strings.ToLower(n)
return strings.HasSuffix(n, ".appimage") && strings.Contains(n, "lug-helper")
})
if err != nil {
return fmt.Errorf("release %s: %w", release.TagName, err)
}
dir := filepath.Join(a.vendorDir, "lug-helper")
metaPath := filepath.Join(dir, "meta.json")
target := filepath.Join(dir, "lug-helper.appimage")
if readMetaVersion(metaPath) == release.TagName {
if fi, err := os.Stat(target); err == nil && fi.Mode().IsRegular() {
return nil
}
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp := target + ".new"
if err := download(asset.BrowserDownloadURL, tmp); err != nil {
return err
}
if err := verifyReleaseDigest(tmp, asset.Digest); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Chmod(tmp, 0o755); err != nil {
return err
}
if err := os.Rename(tmp, target); 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("LUG Helper updated to %s asset=%s", release.TagName, asset.Name)
return nil
}
func (a *App) syncWineRunner() error {
releases, err := githubRecent(wineRepo, 10)
if err != nil {
return err
}
if len(releases) == 0 {
return errors.New("LUG Wine repository returned no releases")
}
base := filepath.Join(a.vendorDir, "wine")
metaPath := filepath.Join(base, "meta.json")
current := filepath.Join(base, "current")
rejectPath := filepath.Join(base, "rejected.json")
fingerprint := compatibilityFingerprint()
rejections := readWineRejections(rejectPath)
if err := os.MkdirAll(base, 0o755); err != nil {
return err
}
if err := os.MkdirAll(a.cacheDir, 0o755); err != nil {
return err
}
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"))
})
if assetErr != nil {
continue
}
// If this exact immutable release already failed on the same hardware /
// libc environment, do not waste bandwidth testing it every six hours.
if rejection, ok := rejections[release.TagName]; ok && rejection.Fingerprint == fingerprint {
failures = append(failures, release.TagName+" cached-incompatible: "+rejection.Reason)
continue
}
// Already-active compatible version: no download/test needed.
if readMetaVersion(metaPath) == release.TagName {
if p, _ := filepath.EvalSymlinks(current); p != "" {
if _, err := os.Stat(filepath.Join(p, "bin", "wine")); err == nil {
a.activateRunnerForPrefix(p)
return nil
}
}
}
archive := filepath.Join(a.cacheDir, "wine-"+release.TagName+"-"+filepath.Base(asset.Name))
if err := download(asset.BrowserDownloadURL, archive); err != nil {
failures = append(failures, release.TagName+" download: "+err.Error())
continue
}
if err := verifyReleaseDigest(archive, asset.Digest); err != nil {
_ = os.Remove(archive)
failures = append(failures, release.TagName+" checksum: "+err.Error())
continue
}
stage, err := os.MkdirTemp(a.cacheDir, "wine-stage-")
if err != nil {
return err
}
extract := exec.Command("tar", "-xf", archive, "-C", stage)
out, extractErr := extract.CombinedOutput()
if extractErr != nil {
os.RemoveAll(stage)
failures = append(failures, release.TagName+" extract: "+formatCommandFailure(extractErr, out))
continue
}
runnerRoot, err := findRootContaining(stage, filepath.Join("bin", "wine"))
if err != nil {
os.RemoveAll(stage)
failures = append(failures, release.TagName+" layout: "+err.Error())
continue
}
diag, testErr := a.selfTestRunnerDetailed(runnerRoot)
if testErr != nil {
reason := testErr.Error()
a.logf("Wine candidate %s rejected: %s | diagnostic=%s", release.TagName, reason, compactDiagnostic(diag))
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+" incompatible: "+reason)
os.RemoveAll(stage)
_ = os.Remove(archive)
continue
}
target := filepath.Join(base, release.TagName)
_ = os.RemoveAll(target + ".new")
if err := copyTree(runnerRoot, target+".new"); err != nil {
os.RemoveAll(stage)
return err
}
_ = os.RemoveAll(target)
if err := os.Rename(target+".new", target); err != nil {
os.RemoveAll(stage)
return err
}
tmpLink := current + ".new"
_ = os.Remove(tmpLink)
if err := os.Symlink(target, tmpLink); err != nil {
os.RemoveAll(stage)
return err
}
if err := os.Rename(tmpLink, current); err != nil {
os.RemoveAll(stage)
return err
}
if err := writeMeta(metaPath, componentMeta{
Version: release.TagName, Asset: asset.Name, URL: asset.BrowserDownloadURL,
Updated: time.Now().Format(time.RFC3339),
}); err != nil {
os.RemoveAll(stage)
return err
}
delete(rejections, release.TagName)
_ = writeWineRejections(rejectPath, rejections)
a.activateRunnerForPrefix(target)
a.logf("Wine runner activated: %s asset=%s diagnostic=%s", release.TagName, asset.Name, compactDiagnostic(diag))
os.RemoveAll(stage)
_ = os.Remove(archive)
return nil
}
if len(failures) == 0 {
return errors.New("no stable LUG Wine asset found in recent releases")
}
return fmt.Errorf("no compatible stable LUG Wine runner found; %s", strings.Join(failures, " | "))
}
func (a *App) selfTestRunner(runner string) error {
_, err := a.selfTestRunnerDetailed(runner)
return err
}
func (a *App) selfTestRunnerDetailed(runner string) (string, error) {
bin := filepath.Join(runner, "bin")
wine := filepath.Join(bin, "wine")
wineboot := filepath.Join(bin, "wineboot")
wineserver := filepath.Join(bin, "wineserver")
for _, required := range []string{wine, wineboot, wineserver} {
fi, err := os.Stat(required)
if err != nil {
return "", fmt.Errorf("runner is incomplete: %s missing", filepath.Base(required))
}
if fi.Mode()&0o111 == 0 {
return "", fmt.Errorf("runner is incomplete: %s is not executable", filepath.Base(required))
}
}
tmp, err := os.MkdirTemp(a.cacheDir, "wine-selftest-prefix-")
if err != nil {
return "", err
}
defer os.RemoveAll(tmp)
env := append(os.Environ(),
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"WINE="+wine,
"WINEPREFIX="+tmp,
"WINEARCH=win64",
"WINEDEBUG=-all",
"WINEDLLOVERRIDES=winemenubuilder.exe=d",
)
var diagnostic []string
// Capture the runner's own version before prefix creation. This also catches
// loader/CPU failures that otherwise produce an empty wineboot stdout.
versionCmd := exec.Command(wine, "--version")
versionCmd.Env = env
versionOut, versionErr := versionCmd.CombinedOutput()
if versionErr != nil {
return strings.Join(diagnostic, "\n"), fmt.Errorf(
"wine --version failed: %s", formatCommandFailure(versionErr, versionOut))
}
diagnostic = append(diagnostic, "wine="+strings.TrimSpace(string(versionOut)))
// Use the runner-provided wineboot directly, matching how regular Wine
// launchers create a prefix. Never fall back to `wine wineboot`.
bootCmd := exec.Command(wineboot, "-u")
bootCmd.Env = env
bootOut, bootErr := bootCmd.CombinedOutput()
if bootErr != nil {
return strings.Join(diagnostic, "\n"), fmt.Errorf(
"wineboot failed: %s", formatCommandFailure(bootErr, bootOut))
}
if text := strings.TrimSpace(string(bootOut)); text != "" {
diagnostic = append(diagnostic, "wineboot="+text)
}
// Let wineserver finish pending registry writes before validating the prefix.
waitCmd := exec.Command(wineserver, "-w")
waitCmd.Env = env
waitOut, waitErr := waitCmd.CombinedOutput()
if waitErr != nil {
return strings.Join(diagnostic, "\n"), fmt.Errorf(
"wineserver -w failed: %s", formatCommandFailure(waitErr, waitOut))
}
deadline := time.Now().Add(20 * time.Second)
for {
ready := true
for _, p := range []string{"drive_c", "system.reg", "user.reg"} {
if _, err := os.Stat(filepath.Join(tmp, p)); err != nil {
ready = false
break
}
}
if ready {
break
}
if time.Now().After(deadline) {
return strings.Join(diagnostic, "\n"),
errors.New("wineboot returned successfully but did not create drive_c/system.reg/user.reg")
}
time.Sleep(250 * time.Millisecond)
}
cmd := exec.Command(wine, "cmd.exe", "/c", "echo", "%APPDATA%")
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return strings.Join(diagnostic, "\n"), fmt.Errorf(
"APPDATA probe failed: %s", formatCommandFailure(err, out))
}
value := strings.TrimSpace(string(out))
diagnostic = append(diagnostic, "APPDATA="+value)
if value == "" || strings.Contains(value, "%APPDATA%") {
return strings.Join(diagnostic, "\n"), errors.New("Wine returned no usable APPDATA path")
}
killCmd := exec.Command(wineserver, "-k")
killCmd.Env = env
_, _ = killCmd.CombinedOutput()
return strings.Join(diagnostic, "\n"), nil
}
func formatCommandFailure(err error, out []byte) string {
text := strings.TrimSpace(string(out))
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
}
func compactDiagnostic(s string) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > 600 {
return s[:600] + "…"
}
return s
}
func (a *App) wineDoctor() error {
current := filepath.Join(a.vendorDir, "wine", "current")
root, err := filepath.EvalSymlinks(current)
if err != nil {
return errors.New("managed Wine runner is not installed yet")
}
fmt.Println("runner=" + root)
if err := a.selfTestRunner(root); err != nil {
fmt.Println("wine_selftest=failed")
return err
}
fmt.Println("wine_selftest=ok")
return nil
}
func (a *App) activateRunnerForPrefix(runner string) {
prefix := a.configuredPrefix()
if prefix == "" || !prefixInitialized(prefix) || prefixBusy(prefix) {
return
}
launch := filepath.Join(prefix, "sc-launch.sh")
data, err := os.ReadFile(launch)
if err != nil {
return
}
want := `export wine_path="` + filepath.Join(runner, "bin") + `"`
lines := strings.Split(string(data), "\n")
found := false
for i, line := range lines {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "export wine_path=") {
lines[i] = want
found = true
break
}
}
if !found {
return
}
tmp := launch + ".new"
if os.WriteFile(tmp, []byte(strings.Join(lines, "\n")), 0o755) == nil {
_ = os.Rename(tmp, launch)
a.logf("launch script switched to managed Wine %s", runner)
}
}
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
}
release, err := githubLatest(dxvkRepo)
if err != nil {
return err
}
asset, err := selectAsset(release.Assets, func(n string) bool {
n = strings.ToLower(n)
return strings.HasPrefix(n, "dxvk-") && strings.HasSuffix(n, ".tar.gz")
})
if err != nil {
return err
}
base := filepath.Join(a.vendorDir, "dxvk")
metaPath := filepath.Join(base, "meta.json")
if readMetaVersion(metaPath) == release.TagName {
return nil
}
if err := os.MkdirAll(base, 0o755); err != nil {
return err
}
if err := os.MkdirAll(a.cacheDir, 0o755); err != nil {
return err
}
archive := filepath.Join(a.cacheDir, filepath.Base(asset.Name))
if err := download(asset.BrowserDownloadURL, archive); err != nil {
return err
}
if err := verifyReleaseDigest(archive, asset.Digest); err != nil {
_ = os.Remove(archive)
return err
}
stage, err := os.MkdirTemp(a.cacheDir, "dxvk-stage-")
if err != nil {
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)))
}
root, err := findRootContaining(stage, filepath.Join("x64", "dxgi.dll"))
if err != nil {
return err
}
system32 := filepath.Join(prefix, "drive_c", "windows", "system32")
syswow64 := filepath.Join(prefix, "drive_c", "windows", "syswow64")
backup := filepath.Join(a.stateDir, "dxvk-backup-"+time.Now().Format("20060102-150405"))
_ = os.MkdirAll(backup, 0o700)
if err := installDLLDir(filepath.Join(root, "x64"), system32, backup); err != nil {
return err
}
if _, err := os.Stat(filepath.Join(root, "x32")); err == nil {
if err := installDLLDir(filepath.Join(root, "x32"), syswow64, backup); 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)
_ = os.Remove(archive)
return nil
}
func installDLLDir(srcDir, dstDir, backup string) error {
if err := os.MkdirAll(dstDir, 0o755); err != nil {
return err
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".dll") {
continue
}
src := filepath.Join(srcDir, e.Name())
dst := filepath.Join(dstDir, e.Name())
if _, err := os.Stat(dst); err == nil {
_ = copyFile(dst, filepath.Join(backup, filepath.Base(dstDir)+"-"+e.Name()), 0o644)
}
tmp := dst + ".new"
if err := copyFile(src, tmp, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, dst); err != nil {
return err
}
}
return nil
}
func (a *App) configuredPrefix() string {
return a.loadGameConfig().Prefix
}
func prefixInitialized(prefix string) bool {
for _, p := range []string{"drive_c", "system.reg", "user.reg"} {
if _, err := os.Stat(filepath.Join(prefix, p)); err != nil {
return false
}
}
return true
}
func prefixBusy(prefix string) bool {
proc, err := os.ReadDir("/proc")
if err != nil {
return false
}
needle := []byte("WINEPREFIX=" + prefix)
for _, e := range proc {
if !e.IsDir() {
continue
}
name := e.Name()
if name == "" || name[0] < '0' || name[0] > '9' {
continue
}
data, err := os.ReadFile(filepath.Join("/proc", name, "environ"))
if err == nil && strings.Contains(string(data), string(needle)) {
return true
}
}
return false
}
func githubRecent(repo string, limit int) ([]githubRelease, error) {
if limit <= 0 {
limit = 10
}
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d", repo, limit)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub %s returned HTTP %d", repo, resp.StatusCode)
}
var releases []githubRelease
if err := json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(&releases); err != nil {
return nil, err
}
return releases, nil
}
func compatibilityFingerprint() string {
var parts []string
if out, err := exec.Command("getconf", "GNU_LIBC_VERSION").CombinedOutput(); err == nil {
parts = append(parts, strings.TrimSpace(string(out)))
}
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(strings.ToLower(line), "flags") {
parts = append(parts, strings.TrimSpace(line))
break
}
}
}
if out, err := exec.Command("uname", "-m").CombinedOutput(); err == nil {
parts = append(parts, strings.TrimSpace(string(out)))
}
return strings.Join(parts, " | ")
}
func readWineRejections(path string) map[string]wineRejection {
result := map[string]wineRejection{}
data, err := os.ReadFile(path)
if err != nil {
return result
}
_ = json.Unmarshal(data, &result)
return result
}
func writeWineRejections(path string, values map[string]wineRejection) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(values, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
tmp := path + ".new"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
func githubLatest(repo string) (githubRelease, error) {
var rel githubRelease
req, err := http.NewRequest("GET", "https://api.github.com/repos/"+repo+"/releases/latest", nil)
if err != nil {
return rel, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return rel, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return rel, fmt.Errorf("GitHub %s returned HTTP %d", repo, resp.StatusCode)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&rel); err != nil {
return rel, err
}
if rel.TagName == "" {
return rel, errors.New("release has no tag")
}
return rel, nil
}
func selectAsset(assets []githubAsset, match func(string) bool) (githubAsset, error) {
var candidates []githubAsset
for _, a := range assets {
if match(a.Name) {
candidates = append(candidates, a)
}
}
if len(candidates) == 0 {
return githubAsset{}, errors.New("no matching release asset")
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name < candidates[j].Name })
return candidates[0], nil
}
func download(url, target string) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 20 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
_, copyErr := io.Copy(f, resp.Body)
closeErr := f.Close()
if copyErr != nil {
_ = os.Remove(target)
return copyErr
}
return closeErr
}
func findRootContaining(base, relative string) (string, error) {
var matches []string
err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() && d.Name() == filepath.Base(filepath.Dir(relative)) {
candidate := filepath.Join(filepath.Dir(path), relative)
if _, err := os.Stat(candidate); err == nil {
matches = append(matches, filepath.Dir(path))
}
}
return nil
})
if err != nil {
return "", err
}
if len(matches) == 0 {
// Robust fallback: inspect every directory for the relative path.
_ = filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
if err == nil && d.IsDir() {
if _, e := os.Stat(filepath.Join(path, relative)); e == nil {
matches = append(matches, path)
}
}
return nil
})
}
if len(matches) == 0 {
return "", fmt.Errorf("archive does not contain %s", relative)
}
sort.Strings(matches)
return matches[0], nil
}
func copyTree(src, dst string) error {
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
info, err := d.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
link, err := os.Readlink(path)
if err != nil {
return err
}
return os.Symlink(link, target)
}
mode := info.Mode().Perm()
if mode == 0 {
mode = 0o644
}
return copyFile(path, target, mode)
})
}
func readMetaVersion(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
var meta componentMeta
if json.Unmarshal(data, &meta) != nil {
return ""
}
return meta.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)
}
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)
}
}
func (a *App) acquireMaintenanceLock() (func(), error) {
if err := os.MkdirAll(a.stateDir, 0o755); err != nil {
return nil, err
}
path := filepath.Join(a.stateDir, "maintenance.lock")
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
return nil, errors.New("another Citizen Launcher maintenance run is active")
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
}, nil
}
func verifyReleaseDigest(path, digest string) error {
if digest == "" {
return nil
}
parts := strings.SplitN(digest, ":", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "sha256" {
return nil
}
want := strings.ToLower(strings.TrimSpace(parts[1]))
got, err := fileHash(path)
if err != nil {
return err
}
if strings.ToLower(got) != want {
return fmt.Errorf("SHA-256 mismatch for %s", filepath.Base(path))
}
return nil
}
@@ -0,0 +1,69 @@
package main
import (
"fmt"
"path/filepath"
"strings"
"testing"
)
func TestSelectLUGAppImage(t *testing.T) {
assets := []githubAsset{
{Name: "lug-helper-v4.16.tar.gz"},
{Name: "lug-helper-v4.16.appimage"},
{Name: "lug-helper-v4.16.appimage.zsync"},
}
a, err := selectAsset(assets, func(n string) bool {
n = lower(n)
return suffix(n, ".appimage") && contains(n, "lug-helper")
})
if err != nil || a.Name != "lug-helper-v4.16.appimage" {
t.Fatalf("unexpected selection: %#v %v", a, err)
}
}
func TestSelectStableWine(t *testing.T) {
assets := []githubAsset{
{Name: "lug-wine-tkg-staging-git-11.16-1-x86_64.tar.xz"},
{Name: "lug-wine-tkg-git-11.16-1-x86_64.tar.xz"},
{Name: "SHA256SUMS"},
}
a, err := selectAsset(assets, func(n string) bool {
n = lower(n)
if contains(n, "staging") || contains(n, "checksum") || contains(n, "sha") {
return false
}
return contains(n, "lug-wine-tkg-git") && suffix(n, ".tar.xz")
})
if err != nil || a.Name != "lug-wine-tkg-git-11.16-1-x86_64.tar.xz" {
t.Fatalf("unexpected selection: %#v %v", a, err)
}
}
// tiny wrappers keep tests readable without importing strings again
func lower(s string) string { return strings.ToLower(s) }
func suffix(s, p string) bool { return strings.HasSuffix(s, p) }
func contains(s, p string) bool { return strings.Contains(s, p) }
func TestFormatCommandFailureIncludesProcessError(t *testing.T) {
err := fmt.Errorf("signal: illegal instruction")
got := formatCommandFailure(err, nil)
if !strings.Contains(got, "illegal instruction") {
t.Fatalf("process error was lost: %q", got)
}
}
func TestWineRejectionRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "rejected.json")
in := map[string]wineRejection{
"11.16-1": {Version: "11.16-1", Reason: "signal: illegal instruction", Fingerprint: "cpu-a"},
}
if err := writeWineRejections(path, in); err != nil {
t.Fatal(err)
}
out := readWineRejections(path)
if out["11.16-1"].Reason != in["11.16-1"].Reason {
t.Fatalf("round trip mismatch: %#v", out)
}
}
+204
View File
@@ -0,0 +1,204 @@
package main
import (
"crypto/rand"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
//go:embed web/*
var guiFiles embed.FS
type GUIStatus struct {
Version string `json:"version"`
Platform PlatformStatus `json:"platform"`
Game GameStatus `json:"game"`
Launcher Status `json:"launcher"`
}
type GUIJob struct {
ID string `json:"id"`
Action string `json:"action"`
State string `json:"state"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
Started string `json:"started"`
Finished string `json:"finished,omitempty"`
}
type jobStore struct {
mu sync.Mutex
jobs map[string]*GUIJob
}
func (a *App) runGUI(args []string) error {
noOpen := hasArg(args, "--no-open")
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
jobs := &jobStore{jobs: map[string]*GUIJob{}}
mux := http.NewServeMux()
sub, _ := fs.Sub(guiFiles, "web")
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+"/" {
http.NotFound(w, r)
return
}
b, err := fs.ReadFile(sub, "index.html")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
b = []byte(strings.ReplaceAll(string(b), "__BASE__", base))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(b)
})
jsonOut := func(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
mux.HandleFunc(base+"/api/status", func(w http.ResponseWriter, r *http.Request) {
st, _ := a.status(false)
jsonOut(w, GUIStatus{Version: appVersion, Platform: detectPlatform(), Game: a.gameStatus(), Launcher: st})
})
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()
if j == nil {
http.NotFound(w, r)
return
}
jsonOut(w, j)
})
mux.HandleFunc(base+"/api/action/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", 405)
return
}
action := strings.TrimPrefix(r.URL.Path, base+"/api/action/")
if action == "launch" {
if err := a.gameLaunch(); err != nil {
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()
go func() {
var err 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()
case "repair":
err = a.gameRepair()
case "maintain":
err = a.maintainGamingStack()
case "doctor":
err = a.wineDoctor()
case "autopilot-enable":
err = a.enableAutopilot()
case "autopilot-disable":
err = a.disableAutopilot()
case "support":
var p string
p, err = a.createSupportBundle()
msg = p
case "integration-update":
err = 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.Error = err.Error()
} else {
j.State = "done"
j.Message = msg
}
}()
jsonOut(w, j)
})
mux.HandleFunc(base+"/api/open/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", 405)
return
}
what := strings.TrimPrefix(r.URL.Path, base+"/api/open/")
var path string
switch what {
case "downloads":
path = filepath.Join(a.home, "Downloads")
case "logs":
path = a.stateDir
default:
http.NotFound(w, r)
return
}
_ = exec.Command("xdg-open", path).Start()
jsonOut(w, map[string]string{"state": "opened"})
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
url := "http://" + listener.Addr().String() + base + "/"
fmt.Println(url)
if !noOpen {
if err := openGUIURL(url, a.cacheDir); err != nil {
_ = listener.Close()
return err
}
}
return srv.Serve(listener)
}
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
if cmd.Start() == nil {
return nil
}
}
}
if p, err := exec.LookPath("xdg-open"); err == nil {
return exec.Command(p, url).Start()
}
if p, err := exec.LookPath("gio"); err == nil {
return exec.Command(p, "open", url).Start()
}
return fmt.Errorf("no browser opener found; open %s manually", url)
}
+846
View File
@@ -0,0 +1,846 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
const (
appVersion = "0.9.0"
pluginID = "local.omarchy-citizen"
appID = "io.github.citizenlauncher.CitizenLauncher"
)
type Config struct {
AutoApply bool `json:"auto_apply"`
AutoMaintain bool `json:"auto_maintain"`
TrustedRemote string `json:"trusted_remote,omitempty"`
LastCheck string `json:"last_check,omitempty"`
LastUpdate string `json:"last_update,omitempty"`
LastResult string `json:"last_result,omitempty"`
LastMaintenance string `json:"last_maintenance,omitempty"`
MaintenanceResult string `json:"maintenance_result,omitempty"`
}
type Status struct {
BackendVersion string `json:"backend_version"`
PluginID string `json:"plugin_id"`
PluginDir string `json:"plugin_dir"`
Managed string `json:"managed"`
AutoApply bool `json:"auto_apply"`
AutoMaintain bool `json:"auto_maintain"`
LUGVersion string `json:"lug_version,omitempty"`
WineVersion string `json:"wine_version,omitempty"`
DXVKVersion string `json:"dxvk_version,omitempty"`
LastMaintenance string `json:"last_maintenance,omitempty"`
MaintenanceResult string `json:"maintenance_result,omitempty"`
Remote string `json:"remote,omitempty"`
TrustedRemote string `json:"trusted_remote,omitempty"`
Branch string `json:"branch,omitempty"`
LocalCommit string `json:"local_commit,omitempty"`
RemoteCommit string `json:"remote_commit,omitempty"`
Dirty bool `json:"dirty"`
Update string `json:"update"`
LastCheck string `json:"last_check,omitempty"`
LastUpdate string `json:"last_update,omitempty"`
LastResult string `json:"last_result,omitempty"`
}
type App struct {
home string
pluginDir string
configDir string
stateDir string
dataDir string
cacheDir string
vendorDir string
configPath string
logPath string
libDir string
selfPath string
}
func main() {
app, err := newApp()
if err != nil {
fatal(err)
}
app.rotateLog()
args := os.Args[1:]
if len(args) == 0 {
args = []string{"status"}
}
switch args[0] {
case "version", "--version", "-v":
fmt.Println(appVersion)
case "status":
st, _ := app.status(false)
if hasArg(args[1:], "--json") {
printJSON(st)
} else {
printStatusKV(st)
}
case "check":
st, err := app.status(true)
app.recordCheck(st, err)
if hasArg(args[1:], "--json") {
printJSON(st)
} else {
printStatusKV(st)
}
if err != nil {
os.Exit(2)
}
case "update":
if err := app.update(false); err != nil {
app.logf("manual update failed: %v", err)
fatal(err)
}
case "tick":
if err := app.tick(); err != nil {
app.logf("tick failed: %v", err)
os.Exit(2)
}
case "maintain":
if err := app.maintainGamingStack(); err != nil {
app.logf("maintenance failed: %v", err)
fatal(err)
}
case "doctor":
if err := app.wineDoctor(); err != nil {
fatal(err)
}
case "game-status":
st := app.gameStatus()
if hasArg(args[1:], "--json") {
printJSON(st)
} else {
printGameStatusKV(st)
}
case "game-install":
if err := app.gameInstall(); err != nil {
fatal(err)
}
case "game-launch":
if err := app.gameLaunch(); err != nil {
fatal(err)
}
case "game-repair":
if err := app.gameRepair(); err != nil {
fatal(err)
}
case "gui":
if err := app.runGUI(args[1:]); err != nil {
fatal(err)
}
case "platform":
ps := detectPlatform()
if hasArg(args[1:], "--json") {
printJSON(ps)
} else {
printPlatformKV(ps)
}
case "support":
path, err := app.createSupportBundle()
if err != nil {
fatal(err)
}
fmt.Println(path)
case "autopilot":
if len(args) < 2 {
fatal(errors.New("usage: autopilot enable|disable|status"))
}
switch args[1] {
case "enable":
if err := app.enableAutopilot(); err != nil {
fatal(err)
}
case "disable":
if err := app.disableAutopilot(); err != nil {
fatal(err)
}
case "status":
cfg := app.loadConfig()
if cfg.AutoMaintain {
fmt.Println("enabled")
} else {
fmt.Println("disabled")
}
default:
fatal(errors.New("usage: autopilot enable|disable|status"))
}
case "auto":
if len(args) < 2 {
fatal(errors.New("usage: auto enable|disable|status"))
}
switch args[1] {
case "enable":
if err := app.enableAuto(); err != nil {
fatal(err)
}
case "disable":
if err := app.disableAuto(); err != nil {
fatal(err)
}
case "status":
cfg := app.loadConfig()
if cfg.AutoApply {
fmt.Println("enabled")
} else {
fmt.Println("disabled")
}
default:
fatal(errors.New("usage: auto enable|disable|status"))
}
case "install-service":
if err := app.installService(); err != nil {
fatal(err)
}
case "uninstall-service":
if err := app.uninstallService(); err != nil {
fatal(err)
}
case "self-sync":
if err := app.selfSync(); err != nil {
fatal(err)
}
default:
fatal(fmt.Errorf("unknown command: %s", args[0]))
}
}
func newApp() (*App, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
self, _ := os.Executable()
self, _ = filepath.EvalSymlinks(self)
configHome := envOr("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
stateHome := envOr("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
dataHome := envOr("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
cacheHome := envOr("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
a := &App{
home: home,
pluginDir: filepath.Join(configHome, "omarchy", "plugins", pluginID),
configDir: filepath.Join(configHome, "citizen-launcher"),
stateDir: filepath.Join(stateHome, "citizen-launcher"),
dataDir: filepath.Join(dataHome, "citizen-launcher"),
cacheDir: filepath.Join(cacheHome, "citizen-launcher"),
configPath: filepath.Join(configHome, "citizen-launcher", "updater.json"),
logPath: filepath.Join(stateHome, "citizen-launcher", "updater.log"),
libDir: filepath.Join(home, ".local", "lib", "citizen-launcher"),
selfPath: self,
}
a.vendorDir = filepath.Join(a.dataDir, "vendor")
_ = a.migrateLegacyPaths(configHome, stateHome, dataHome)
return a, nil
}
func (a *App) migrateLegacyPaths(configHome, stateHome, dataHome string) error {
pairs := [][2]string{
{filepath.Join(configHome, "omarchy-citizen"), a.configDir},
{filepath.Join(stateHome, "omarchy-citizen"), a.stateDir},
{filepath.Join(dataHome, "omarchy-citizen"), a.dataDir},
}
for _, pair := range pairs {
if _, err := os.Stat(pair[1]); err == nil {
continue
}
if fi, err := os.Stat(pair[0]); err == nil && fi.IsDir() {
_ = os.MkdirAll(filepath.Dir(pair[1]), 0o755)
if err := os.Rename(pair[0], pair[1]); err != nil {
// Cross-filesystem and permission-safe fallback: leave legacy data in place.
// loadGameConfig() separately adopts the legacy Star Citizen prefix.
a.logf("legacy migration warning %s -> %s: %v", pair[0], pair[1], err)
}
}
}
return nil
}
func (a *App) status(fetch bool) (Status, error) {
cfg := a.loadConfig()
st := Status{
BackendVersion: appVersion,
PluginID: pluginID,
PluginDir: a.pluginDir,
Managed: "manual",
AutoApply: cfg.AutoApply,
AutoMaintain: cfg.AutoMaintain,
LUGVersion: readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json")),
WineVersion: readMetaVersion(filepath.Join(a.vendorDir, "wine", "meta.json")),
DXVKVersion: readMetaVersion(filepath.Join(a.vendorDir, "dxvk", "meta.json")),
LastMaintenance: cfg.LastMaintenance,
MaintenanceResult: cfg.MaintenanceResult,
TrustedRemote: cfg.TrustedRemote,
Update: "unavailable",
LastCheck: cfg.LastCheck,
LastUpdate: cfg.LastUpdate,
LastResult: cfg.LastResult,
}
if !isGitRepo(a.pluginDir) || !commandExists("omarchy") {
st.Managed = "standalone"
st.Update = "external-release"
return st, nil
}
st.Managed = "omarchy-git"
remote, _ := git(a.pluginDir, "remote", "get-url", "origin")
st.Remote = strings.TrimSpace(remote)
branch, _ := git(a.pluginDir, "branch", "--show-current")
st.Branch = strings.TrimSpace(branch)
local, _ := git(a.pluginDir, "rev-parse", "HEAD")
st.LocalCommit = short(strings.TrimSpace(local))
dirty, _ := git(a.pluginDir, "status", "--porcelain")
st.Dirty = strings.TrimSpace(dirty) != ""
if fetch {
if st.Dirty {
st.Update = "blocked-dirty"
return st, errors.New("plugin checkout has local changes")
}
if cfg.TrustedRemote != "" && st.Remote != cfg.TrustedRemote {
st.Update = "blocked-remote-changed"
return st, errors.New("git origin differs from trusted remote")
}
if _, err := git(a.pluginDir, "fetch", "--quiet", "origin"); err != nil {
st.Update = "check-failed"
return st, err
}
}
upstream, err := git(a.pluginDir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
if err != nil {
st.Update = "no-upstream"
return st, nil
}
upstream = strings.TrimSpace(upstream)
remoteCommit, err := git(a.pluginDir, "rev-parse", upstream)
if err != nil {
st.Update = "unknown"
return st, err
}
remoteCommit = strings.TrimSpace(remoteCommit)
st.RemoteCommit = short(remoteCommit)
localFull, _ := git(a.pluginDir, "rev-parse", "HEAD")
localFull = strings.TrimSpace(localFull)
switch {
case st.Dirty:
st.Update = "blocked-dirty"
case localFull == remoteCommit:
st.Update = "current"
default:
ancestor, err := runExit(a.pluginDir, "git", "merge-base", "--is-ancestor", localFull, remoteCommit)
if err == nil && ancestor == 0 {
st.Update = "available"
} else {
st.Update = "diverged"
}
}
return st, nil
}
func (a *App) tick() error {
cfg := a.loadConfig()
var problems []string
if cfg.AutoMaintain {
if err := a.maintainGamingStack(); err != nil {
problems = append(problems, "gaming stack: "+err.Error())
}
}
if cfg.AutoApply {
st, err := a.status(true)
a.recordCheck(st, err)
if err != nil {
problems = append(problems, "plugin check: "+err.Error())
} else if st.Update == "available" {
if err := a.update(true); err != nil {
problems = append(problems, "plugin update: "+err.Error())
}
}
}
if len(problems) > 0 {
return errors.New(strings.Join(problems, "; "))
}
return nil
}
func (a *App) update(automatic bool) error {
unlock, err := a.acquireUpdateLock()
if err != nil {
return err
}
defer unlock()
st, err := a.status(true)
a.recordCheck(st, err)
if err != nil {
return err
}
if st.Managed != "omarchy-git" {
return errors.New("this update path is only available for the optional Omarchy Git integration")
}
cfg := a.loadConfig()
if cfg.TrustedRemote != "" && st.Remote != cfg.TrustedRemote {
return errors.New("refusing update: git origin changed after auto-update trust was granted")
}
if st.Dirty {
return errors.New("refusing update: plugin contains local changes")
}
if st.Update == "current" {
a.logf("update: already current at %s", st.LocalCommit)
return nil
}
if st.Update != "available" {
return fmt.Errorf("update cannot be applied in state %q", st.Update)
}
before := st.LocalCommit
a.logf("update begin automatic=%v remote=%q from=%s to=%s", automatic, st.Remote, st.LocalCommit, st.RemoteCommit)
cmd := exec.Command("omarchy", "plugin", "update", pluginID, "--yes")
cmd.Stdout = io.MultiWriter(os.Stdout, a.logWriter())
cmd.Stderr = io.MultiWriter(os.Stderr, a.logWriter())
if err := cmd.Run(); err != nil {
a.updateConfig(func(c *Config) {
c.LastResult = "update-failed"
})
return fmt.Errorf("omarchy plugin update failed: %w", err)
}
if err := a.validatePlugin(); err != nil {
a.updateConfig(func(c *Config) {
c.LastResult = "post-validation-failed"
})
return fmt.Errorf("post-update validation failed: %w", err)
}
after, _ := git(a.pluginDir, "rev-parse", "HEAD")
after = short(strings.TrimSpace(after))
a.updateConfig(func(c *Config) {
c.LastUpdate = time.Now().Format(time.RFC3339)
c.LastResult = "updated:" + before + "->" + after
})
a.logf("update success from=%s to=%s", before, after)
a.notify("Citizen Launcher", "Plugin-Update installiert: "+before+" → "+after)
if err := a.selfSync(); err != nil {
a.logf("backend self-sync warning: %v", err)
}
_ = run("", "omarchy-shell", "shell", "rescanPlugins")
if !automatic {
// Current Omarchy releases can keep stale third-party bar QML after a rescan.
// A user-triggered update may safely request a full shell restart so the
// freshly validated plugin code becomes active immediately.
_ = run("", "omarchy", "restart", "shell")
}
return nil
}
func (a *App) enableAuto() error {
st, err := a.status(true)
if err != nil {
return err
}
if st.Managed != "omarchy-git" || st.Remote == "" {
return errors.New("integration auto-updates require the optional Omarchy Git integration")
}
if st.Dirty {
return errors.New("cannot enable auto updates while the plugin checkout has local changes")
}
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 {
return err
}
a.logf("auto updates enabled trusted_remote=%q", st.Remote)
fmt.Println("enabled")
return nil
}
func (a *App) disableAuto() error {
a.updateConfig(func(c *Config) {
c.AutoApply = false
c.LastResult = "auto-disabled"
})
_ = run("", "systemctl", "--user", "disable", "--now", "citizen-launcher-maintenance.timer")
a.logf("auto updates disabled")
fmt.Println("disabled")
return nil
}
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")
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`
if err := os.WriteFile(servicePath, []byte(strings.ReplaceAll(service, `\n`, "\n")), 0o644); err != nil {
return err
}
if err := os.WriteFile(timerPath, []byte(strings.ReplaceAll(timer, `\n`, "\n")), 0o644); err != nil {
return err
}
return run("", "systemctl", "--user", "daemon-reload")
}
func (a *App) uninstallService() error {
_ = run("", "systemctl", "--user", "disable", "--now", "citizen-launcher-maintenance.timer")
_ = os.Remove(filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.service"))
_ = os.Remove(filepath.Join(a.home, ".config", "systemd", "user", "citizen-launcher-maintenance.timer"))
_ = run("", "systemctl", "--user", "daemon-reload")
return nil
}
func (a *App) selfSync() error {
if err := os.MkdirAll(a.libDir, 0o755); err != nil {
return err
}
target := filepath.Join(a.libDir, "citizen-launcher")
source := a.selfPath
// Omarchy integration ships a bundled copy; prefer it when present.
bundled := filepath.Join(a.pluginDir, "backend", "bin", "citizen-launcher")
if _, err := os.Stat(bundled); err == nil {
source = bundled
}
if source == "" {
return errors.New("cannot locate Citizen Launcher executable")
}
same, _ := sameFileHash(source, target)
if !same {
tmp := target + ".new"
if err := copyFile(source, tmp, 0o755); err != nil {
return err
}
if err := os.Rename(tmp, target); err != nil {
return err
}
}
// Compatibility alias for older Omarchy Citizen adapters.
legacyDir := filepath.Join(a.home, ".local", "lib", "omarchy-citizen")
_ = os.MkdirAll(legacyDir, 0o755)
legacy := filepath.Join(legacyDir, "omarchy-citizen-backend")
_ = os.Remove(legacy)
_ = os.Symlink(target, legacy)
a.logf("self-sync installed %s", target)
return nil
}
func (a *App) acquireUpdateLock() (func(), error) {
if err := os.MkdirAll(a.stateDir, 0o755); err != nil {
return nil, err
}
path := filepath.Join(a.stateDir, "update.lock")
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
return nil, errors.New("another Omarchy Citizen update is already running")
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
}, nil
}
func (a *App) notify(title, body string) {
if _, err := exec.LookPath("omarchy-notification-send"); err == nil {
_ = exec.Command("omarchy-notification-send", title, body).Run()
return
}
if _, err := exec.LookPath("notify-send"); err == nil {
_ = exec.Command("notify-send", title, body).Run()
}
}
func (a *App) validatePlugin() error {
cmd := exec.Command("omarchy", "plugin", "validate", a.pluginDir)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("plugin validation failed: %s", strings.TrimSpace(string(out)))
}
return nil
}
func (a *App) recordCheck(st Status, err error) {
a.updateConfig(func(c *Config) {
c.LastCheck = time.Now().Format(time.RFC3339)
if err != nil {
c.LastResult = "check-failed:" + err.Error()
} else {
c.LastResult = "check:" + st.Update
}
})
a.logf("check managed=%s update=%s local=%s remote=%s dirty=%v err=%v", st.Managed, st.Update, st.LocalCommit, st.RemoteCommit, st.Dirty, err)
}
func (a *App) loadConfig() Config {
var cfg Config
data, err := os.ReadFile(a.configPath)
if err == nil {
_ = json.Unmarshal(data, &cfg)
}
return cfg
}
func (a *App) updateConfig(fn func(*Config)) {
cfg := a.loadConfig()
fn(&cfg)
_ = 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)
}
}
func (a *App) rotateLog() {
_ = os.MkdirAll(a.stateDir, 0o755)
info, err := os.Stat(a.logPath)
if err != nil || info.Size() <= 4*1024*1024 {
return
}
f, err := os.Open(a.logPath)
if err != nil {
return
}
defer f.Close()
keep := int64(2 * 1024 * 1024)
if _, err := f.Seek(-keep, io.SeekEnd); err != nil {
return
}
data, _ := io.ReadAll(f)
_ = os.WriteFile(a.logPath, data, 0o600)
}
func (a *App) logWriter() io.Writer {
_ = os.MkdirAll(a.stateDir, 0o755)
f, err := os.OpenFile(a.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return io.Discard
}
return &autoCloseWriter{f: f}
}
func (a *App) logf(format string, args ...any) {
_ = os.MkdirAll(a.stateDir, 0o755)
f, err := os.OpenFile(a.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "%s version=%s %s\n", time.Now().Format(time.RFC3339), appVersion, fmt.Sprintf(format, args...))
}
type autoCloseWriter struct{ f *os.File }
func (w *autoCloseWriter) Write(p []byte) (int, error) { return w.f.Write(p) }
func git(dir string, args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out)))
}
return string(out), nil
}
func run(dir, name string, args ...string) error {
cmd := exec.Command(name, args...)
if dir != "" {
cmd.Dir = dir
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func runExit(dir, name string, args ...string) (int, error) {
cmd := exec.Command(name, args...)
if dir != "" {
cmd.Dir = dir
}
err := cmd.Run()
if err == nil {
return 0, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode(), nil
}
return -1, err
}
func isGitRepo(dir string) bool {
out, err := git(dir, "rev-parse", "--is-inside-work-tree")
return err == nil && strings.TrimSpace(out) == "true"
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if err := out.Sync(); err != nil {
out.Close()
return err
}
return out.Close()
}
func sameFileHash(a, b string) (bool, error) {
ha, err := fileHash(a)
if err != nil {
return false, err
}
hb, err := fileHash(b)
if err != nil {
return false, err
}
return ha == hb, nil
}
func fileHash(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func short(s string) string {
if len(s) > 12 {
return s[:12]
}
return s
}
func hasArg(args []string, needle string) bool {
for _, a := range args {
if a == needle {
return true
}
}
return false
}
func printJSON(v any) { enc := json.NewEncoder(os.Stdout); enc.SetIndent("", " "); _ = enc.Encode(v) }
func printStatusKV(s Status) {
fmt.Printf("backend_version=%s\n", s.BackendVersion)
fmt.Printf("managed=%s\n", s.Managed)
fmt.Printf("auto_apply=%t\n", s.AutoApply)
fmt.Printf("auto_maintain=%t\n", s.AutoMaintain)
fmt.Printf("lug_version=%s\n", s.LUGVersion)
fmt.Printf("wine_version=%s\n", s.WineVersion)
fmt.Printf("dxvk_version=%s\n", s.DXVKVersion)
fmt.Printf("last_maintenance=%s\n", s.LastMaintenance)
fmt.Printf("maintenance_result=%s\n", s.MaintenanceResult)
fmt.Printf("update=%s\n", s.Update)
fmt.Printf("remote=%s\n", s.Remote)
fmt.Printf("trusted_remote=%s\n", s.TrustedRemote)
fmt.Printf("branch=%s\n", s.Branch)
fmt.Printf("local_commit=%s\n", s.LocalCommit)
fmt.Printf("remote_commit=%s\n", s.RemoteCommit)
fmt.Printf("dirty=%t\n", s.Dirty)
fmt.Printf("last_check=%s\n", s.LastCheck)
fmt.Printf("last_update=%s\n", s.LastUpdate)
fmt.Printf("last_result=%s\n", s.LastResult)
}
func commandExists(name string) bool { _, err := exec.LookPath(name); return err == nil }
func printPlatformKV(p PlatformStatus) {
fmt.Printf("id=%s\n", p.ID)
fmt.Printf("name=%s\n", p.Name)
fmt.Printf("version=%s\n", p.Version)
fmt.Printf("id_like=%s\n", p.IDLike)
fmt.Printf("package_manager=%s\n", p.PackageManager)
fmt.Printf("desktop=%s\n", p.Desktop)
fmt.Printf("session=%s\n", p.Session)
fmt.Printf("systemd_user=%t\n", p.SystemdUser)
fmt.Printf("omarchy=%t\n", p.Omarchy)
}
func fatal(err error) { fmt.Fprintln(os.Stderr, "citizen-launcher:", err); os.Exit(1) }
func printGameStatusKV(s GameStatus) {
fmt.Printf("backend_version=%s\n", s.BackendVersion)
fmt.Printf("health=%s\n", s.Health)
fmt.Printf("hardware=%s\n", s.Hardware)
fmt.Printf("hardware_reason=%s\n", s.HardwareReason)
fmt.Printf("gpu=%s\n", s.GPU)
fmt.Printf("vulkan=%s\n", s.Vulkan)
fmt.Printf("cpu_avx=%t\n", s.CPUAVX)
fmt.Printf("ram_gib=%d\n", s.RAMGiB)
fmt.Printf("combined_gib=%d\n", s.CombinedGiB)
fmt.Printf("disk_free_gib=%d\n", s.DiskFreeGiB)
fmt.Printf("prefix=%s\n", s.Prefix)
fmt.Printf("prefix_state=%s\n", s.PrefixState)
fmt.Printf("launcher_state=%s\n", s.LauncherState)
fmt.Printf("game_state=%s\n", s.GameState)
fmt.Printf("wine_version=%s\n", s.WineVersion)
fmt.Printf("dxvk_version=%s\n", s.DXVKVersion)
fmt.Printf("lug_version=%s\n", s.LUGVersion)
fmt.Printf("rsi_installer=%s\n", s.RSIInstaller)
fmt.Printf("autopilot=%t\n", s.Autopilot)
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"bufio"
"os"
"os/exec"
"strings"
)
type PlatformStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
IDLike string `json:"id_like,omitempty"`
PackageManager string `json:"package_manager"`
Desktop string `json:"desktop,omitempty"`
Session string `json:"session,omitempty"`
SystemdUser bool `json:"systemd_user"`
Omarchy bool `json:"omarchy"`
}
func detectPlatform() PlatformStatus {
p := PlatformStatus{ID: "linux", Name: "Linux"}
if f, err := os.Open("/etc/os-release"); err == nil {
defer f.Close()
vals := map[string]string{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if ok {
vals[k] = strings.Trim(strings.TrimSpace(v), `"'`)
}
}
if vals["ID"] != "" {
p.ID = vals["ID"]
}
if vals["PRETTY_NAME"] != "" {
p.Name = vals["PRETTY_NAME"]
} else if vals["NAME"] != "" {
p.Name = vals["NAME"]
}
p.Version = vals["VERSION_ID"]
p.IDLike = vals["ID_LIKE"]
}
switch {
case commandExists("apt-get"):
p.PackageManager = "apt"
case commandExists("dnf"):
p.PackageManager = "dnf"
case commandExists("pacman"):
p.PackageManager = "pacman"
case commandExists("zypper"):
p.PackageManager = "zypper"
case commandExists("apk"):
p.PackageManager = "apk"
default:
p.PackageManager = "unknown"
}
p.Desktop = envOr("XDG_CURRENT_DESKTOP", envOr("DESKTOP_SESSION", ""))
p.Session = envOr("XDG_SESSION_TYPE", "")
if commandExists("systemctl") {
cmd := exec.Command("systemctl", "--user", "show-environment")
p.SystemdUser = cmd.Run() == nil
}
p.Omarchy = commandExists("omarchy")
return p
}
+826
View File
@@ -0,0 +1,826 @@
package main
import (
"bufio"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
const (
winetricksRepo = "Winetricks/winetricks"
rsiBaseURL = "https://install.robertsspaceindustries.com/rel/2"
rsiLatestYML = rsiBaseURL + "/latest.yml"
)
type GameConfig struct {
Prefix string `json:"prefix"`
GameDir string `json:"game_dir"`
LauncherEXE string `json:"launcher_exe"`
RSIInstaller string `json:"rsi_installer,omitempty"`
WinetricksVersion string `json:"winetricks_version,omitempty"`
InstalledAt string `json:"installed_at,omitempty"`
LastRepair string `json:"last_repair,omitempty"`
}
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"`
}
func (a *App) gameConfigPath() string { return filepath.Join(a.configDir, "game.json") }
func (a *App) loadGameConfig() GameConfig {
cfg := GameConfig{}
if data, err := os.ReadFile(a.gameConfigPath()); err == nil {
_ = json.Unmarshal(data, &cfg)
}
if cfg.Prefix == "" {
// Adopt an existing LUG prefix if one is configured, otherwise use our stable default.
old := filepath.Join(envOr("XDG_CONFIG_HOME", filepath.Join(a.home, ".config")), "starcitizen-lug", "winedir.conf")
if data, err := os.ReadFile(old); err == nil && strings.TrimSpace(string(data)) != "" {
cfg.Prefix = strings.TrimSpace(string(data))
} else {
cfg.Prefix = filepath.Join(a.home, "Games", "star-citizen")
}
}
if cfg.GameDir == "" {
cfg.GameDir = filepath.Join(cfg.Prefix, "drive_c", "Program Files", "Roberts Space Industries", "StarCitizen")
}
if cfg.LauncherEXE == "" {
cfg.LauncherEXE = filepath.Join(cfg.Prefix, "drive_c", "Program Files", "Roberts Space Industries", "RSI Launcher", "RSI Launcher.exe")
}
return cfg
}
func (a *App) saveGameConfig(cfg GameConfig) error {
if err := os.MkdirAll(a.configDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
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())
}
func (a *App) gameStatus() GameStatus {
gc := a.loadGameConfig()
uc := a.loadConfig()
hs := a.hardwareStatus(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,
}
if hs.State == "blocked" {
st.Health = "hardware-blocked"
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 _, err := os.Stat(gc.LauncherEXE); err == nil {
st.LauncherState = "ready"
}
if _, err := os.Stat(filepath.Join(gc.GameDir, "LIVE", "Bin64", "StarCitizen.exe")); err == nil {
st.GameState = "ready"
} else if _, err := os.Stat(filepath.Join(gc.GameDir, "LIVE", "Data.p4k")); err == nil {
st.GameState = "partial"
}
switch {
case st.WineVersion == "":
st.Health = "setup"
case st.PrefixState == "partial":
st.Health = "repair"
case st.PrefixState != "ready":
st.Health = "install"
case st.LauncherState != "ready":
st.Health = "launcher-repair"
case st.GameState != "ready":
st.Health = "install-game"
default:
st.Health = "ready"
}
return st
}
type hardwareInfo struct {
State string
Reason string
GPU string
Vulkan string
AVX bool
RAMGiB int
CombinedGiB int
DiskFreeGiB int
}
func (a *App) hardwareStatus(prefix string) hardwareInfo {
h := hardwareInfo{State: "ready", Vulkan: "unknown"}
if runtime.GOARCH != "amd64" {
h.State = "blocked"
h.Reason = "Star Citizen benötigt x86-64."
return h
}
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
low := strings.ToLower(string(data))
h.AVX = regexp.MustCompile(`(?m)^flags\s*:.*\bavx\b`).MatchString(low)
if !h.AVX {
h.State = "blocked"
h.Reason = "Die CPU stellt AVX nicht bereit."
}
}
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
vals := map[string]int64{}
sc := bufio.NewScanner(strings.NewReader(string(data)))
for sc.Scan() {
f := strings.Fields(sc.Text())
if len(f) >= 2 {
n, _ := strconv.ParseInt(f[1], 10, 64)
vals[strings.TrimSuffix(f[0], ":")] = n
}
}
h.RAMGiB = int(vals["MemTotal"] / (1024 * 1024))
h.CombinedGiB = int((vals["MemTotal"] + vals["SwapTotal"]) / (1024 * 1024))
if h.RAMGiB < 14 && h.State != "blocked" {
h.State = "blocked"
h.Reason = "Weniger als 16 GiB RAM erkannt."
}
}
var stat syscallStatfs
if statfs(prefix, &stat) == nil {
h.DiskFreeGiB = int((stat.Bavail * uint64(stat.Bsize)) / (1024 * 1024 * 1024))
} else if statfs(a.home, &stat) == nil {
h.DiskFreeGiB = int((stat.Bavail * uint64(stat.Bsize)) / (1024 * 1024 * 1024))
}
pci, _ := exec.Command("lspci", "-nnk").CombinedOutput()
p := string(pci)
if strings.TrimSpace(p) == "" {
// lspci/pciutils is optional on minimal Debian installations. Detect the
// QEMU VGA device through sysfs as a dependency-free fallback.
vendors, _ := filepath.Glob("/sys/class/drm/card*/device/vendor")
for _, vp := range vendors {
v, _ := os.ReadFile(vp)
d, _ := os.ReadFile(filepath.Join(filepath.Dir(vp), "device"))
if strings.TrimSpace(string(v)) == "0x1234" && strings.TrimSpace(string(d)) == "0x1111" {
p = "QEMU Standard VGA [1234:1111]\nKernel driver in use: bochs-drm"
break
}
}
}
for _, line := range strings.Split(p, "\n") {
low := strings.ToLower(line)
if strings.Contains(low, "vga compatible controller") || strings.Contains(low, "3d controller") || strings.Contains(low, "display controller") {
if h.GPU == "" {
h.GPU = strings.TrimSpace(line)
}
}
}
lowp := strings.ToLower(p)
if strings.Contains(lowp, "1234:1111") || strings.Contains(lowp, "kernel driver in use: bochs") {
h.State = "blocked"
h.Vulkan = "blocked"
h.Reason = "QEMU Standard VGA/bochs-drm erkannt. Star Citizen benötigt eine echte Vulkan-GPU oder GPU-Passthrough."
return h
}
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") {
h.State = "blocked"
h.Vulkan = "blocked"
h.Reason = "Vulkan ist nicht funktionsfähig."
return h
}
h.Vulkan = "ready"
} else {
matches, _ := filepath.Glob("/usr/share/vulkan/icd.d/*.json")
if len(matches) > 0 {
h.Vulkan = "probable"
} else {
h.Vulkan = "unknown"
}
}
return h
}
// Small local wrapper lets this file remain platform-specific only where needed.
type syscallStatfs struct {
Type, Bsize int64
Blocks, Bfree, Bavail, Files, Ffree, Fsid0, Fsid1 uint64
Namelen, Frsize, Flags int64
Spare [4]int64
}
func statfs(path string, s *syscallStatfs) error {
// Use df instead of syscall.Statfs to keep the struct portable across Go versions.
out, err := exec.Command("df", "-Pk", path).CombinedOutput()
if err != nil {
return err
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return errors.New("df output")
}
f := strings.Fields(lines[len(lines)-1])
if len(f) < 4 {
return errors.New("df fields")
}
avail, _ := strconv.ParseUint(f[3], 10, 64)
s.Bavail = avail
s.Bsize = 1024
return nil
}
func (a *App) currentRunner() (string, error) {
p, err := filepath.EvalSymlinks(filepath.Join(a.vendorDir, "wine", "current"))
if err != nil {
return "", errors.New("kein kompatibler verwalteter Wine-Runner aktiv")
}
if _, err := os.Stat(filepath.Join(p, "bin", "wine")); err != nil {
return "", err
}
return p, nil
}
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 = append(env,
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"WINE="+filepath.Join(bin, "wine"),
"WINESERVER="+filepath.Join(bin, "wineserver"),
"WINEPREFIX="+prefix,
"WINEARCH=win64",
"WINEDEBUG=-all",
"WINEDLLOVERRIDES=dxwebsetup.exe,dotNetFx45_Full_setup.exe,winemenubuilder.exe=d",
)
return env, runner, nil
}
func (a *App) ensureLUGRuntime() (string, error) {
appimage := filepath.Join(a.vendorDir, "lug-helper", "lug-helper.appimage")
ver := readMetaVersion(filepath.Join(a.vendorDir, "lug-helper", "meta.json"))
if appimage == "" || ver == "" {
return "", errors.New("LUG AppImage runtime ist noch nicht synchronisiert")
}
base := filepath.Join(a.vendorDir, "lug-helper", "runtime", ver)
if _, err := os.Stat(filepath.Join(base, "usr", "bin", "cabextract")); err == nil {
return base, nil
}
if err := os.MkdirAll(filepath.Dir(base), 0o755); err != nil {
return "", err
}
tmp, err := os.MkdirTemp(a.cacheDir, "lug-appimage-")
if err != nil {
return "", err
}
defer os.RemoveAll(tmp)
cmd := exec.Command(appimage, "--appimage-extract")
cmd.Dir = tmp
cmd.Env = append(os.Environ(), "APPIMAGE_EXTRACT_AND_RUN=1")
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("LUG AppImage Runtime konnte nicht entpackt werden: %s", formatCommandFailure(err, out))
}
extracted := filepath.Join(tmp, "squashfs-root")
if _, err := os.Stat(extracted); err != nil {
return "", errors.New("AppImage-Extraktion erzeugte kein squashfs-root")
}
_ = os.RemoveAll(base + ".new")
if err := copyTree(extracted, base+".new"); err != nil {
return "", err
}
_ = os.RemoveAll(base)
if err := os.Rename(base+".new", base); err != nil {
return "", err
}
return base, nil
}
func (a *App) toolsEnv(base []string) ([]string, error) {
// Prefer ordinary distro tools. Debian packages install these explicitly,
// while most desktop distributions already provide curl/unzip.
required := []string{"cabextract", "curl", "unzip"}
allSystem := true
for _, tool := range required {
if _, err := exec.LookPath(tool); err != nil {
allSystem = false
break
}
}
if allSystem {
return append([]string{}, base...), nil
}
// Portable fallback: use only the extracted runtime of the optional LUG
// AppImage as a toolbox. The LUG installer itself is never invoked.
if err := a.syncLUGHelper(); err != nil {
return base, fmt.Errorf("Systemwerkzeuge fehlen (%s) und portable Toolbox konnte nicht geladen werden: %w", strings.Join(required, ", "), err)
}
runtimeRoot, err := a.ensureLUGRuntime()
if err != nil {
return base, err
}
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")
ld := strings.Join(libdirs, string(os.PathListSeparator))
if existingLD != "" {
ld += string(os.PathListSeparator) + existingLD
}
env = append(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")
}
dir := filepath.Join(a.vendorDir, "winetricks", tag)
target := filepath.Join(dir, "winetricks")
if _, err := os.Stat(target); err == nil {
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 {
return "", "", err
}
if err := os.Chmod(target+".new", 0o755); err != nil {
return "", "", err
}
if err := os.Rename(target+".new", target); err != nil {
return "", "", err
}
return target, tag, nil
}
func (a *App) latestRSIInstaller() (string, string, string, error) {
req, _ := http.NewRequest("GET", rsiLatestYML, nil)
req.Header.Set("User-Agent", "citizen-launcher/"+appVersion)
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", "", fmt.Errorf("RSI latest.yml HTTP %d", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", "", "", err
}
reURL := regexp.MustCompile(`(?m)^url:\s*(.+?)\s*$`)
m := reURL.FindSubmatch(b)
if len(m) < 2 {
return "", "", "", errors.New("RSI latest.yml Format unbekannt")
}
file := strings.TrimSpace(string(m[1]))
file = strings.Trim(file, "\"'")
sha := ""
reSHA := regexp.MustCompile(`(?m)^sha512:\s*(.+?)\s*$`)
if sm := reSHA.FindSubmatch(b); len(sm) >= 2 {
sha = strings.TrimSpace(string(sm[1]))
sha = strings.Trim(sha, "\"'")
}
base, _ := neturl.Parse(rsiBaseURL + "/")
ref := &neturl.URL{Path: file}
return file, base.ResolveReference(ref).String(), sha, nil
}
func verifySHA512Base64(path, want string) error {
if strings.TrimSpace(want) == "" {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha512.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
got := base64.StdEncoding.EncodeToString(h.Sum(nil))
if got != strings.TrimSpace(want) {
return fmt.Errorf("SHA-512 mismatch for %s", filepath.Base(path))
}
return nil
}
func (a *App) backupIncompletePrefix(gc GameConfig) (GameConfig, string, error) {
fi, err := os.Stat(gc.Prefix)
if err != nil || !fi.IsDir() {
return gc, "", nil
}
if prefixInitialized(gc.Prefix) {
return gc, "", nil
}
if entries, err := os.ReadDir(gc.Prefix); err == nil && len(entries) == 0 {
return gc, "", nil
}
stamp := time.Now().Format("20060102-150405")
backup := gc.Prefix + "-incomplete-" + stamp
if err := os.Rename(gc.Prefix, backup); err != nil {
return gc, "", err
}
a.logf("backed up incomplete prefix %s -> %s", gc.Prefix, backup)
return gc, backup, nil
}
func (a *App) gameInstall() error {
unlock, err := a.acquireMaintenanceLock()
if err != nil {
return err
}
defer unlock()
gc := a.loadGameConfig()
hs := a.hardwareStatus(gc.Prefix)
if hs.State == "blocked" {
return fmt.Errorf("hardware nicht spielbereit: %s", hs.Reason)
}
// LUG is not part of the installation control path. A portable runtime is
// fetched lazily only when required system tools are missing.
if err := a.syncWineRunner(); err != nil {
return fmt.Errorf("Wine: %w", err)
}
if _, _, err := a.syncWinetricks(); err != nil {
return fmt.Errorf("Winetricks: %w", err)
}
// Adopt a complete existing prefix. Otherwise safely back up partial state.
if !prefixInitialized(gc.Prefix) {
_, backup, err := a.backupIncompletePrefix(gc)
if err != nil {
return err
}
if backup != "" {
a.logf("partial prefix preserved at %s", backup)
}
if err := os.MkdirAll(gc.Prefix, 0o755); err != nil {
return err
}
if err := a.initializePrefix(gc); err != nil {
return err
}
}
if err := a.ensurePrefixComponents(gc); err != nil {
return err
}
if err := a.ensureRSILauncher(gc, true); err != nil {
return err
}
if err := a.writeOwnedLaunchFiles(gc); err != nil {
return err
}
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"))
gc.InstalledAt = time.Now().Format(time.RFC3339)
if err := a.saveGameConfig(gc); err != nil {
return err
}
a.logf("game stack installed prefix=%s wine=%s", gc.Prefix, readMetaVersion(filepath.Join(a.vendorDir, "wine", "meta.json")))
return nil
}
func (a *App) initializePrefix(gc GameConfig) error {
env, runner, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
}
env, err = a.toolsEnv(env)
if err != nil {
return err
}
bin := filepath.Join(runner, "bin")
cmd := exec.Command(filepath.Join(bin, "wineboot"), "-u")
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("Wine-Prefix konnte nicht erstellt werden: %s", formatCommandFailure(err, out))
}
wait := exec.Command(filepath.Join(bin, "wineserver"), "-w")
wait.Env = env
_, _ = wait.CombinedOutput()
deadline := time.Now().Add(30 * time.Second)
for !prefixInitialized(gc.Prefix) && time.Now().Before(deadline) {
time.Sleep(300 * time.Millisecond)
}
if !prefixInitialized(gc.Prefix) {
return errors.New("wineboot meldete Erfolg, aber drive_c/system.reg/user.reg fehlen")
}
return nil
}
func (a *App) ensurePrefixComponents(gc GameConfig) error {
wt, tag, err := a.syncWinetricks()
if err != nil {
return err
}
env, _, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
}
env, err = a.toolsEnv(env)
if err != nil {
return err
}
cache := filepath.Join(a.cacheDir, "winetricks-cache", tag)
_ = os.MkdirAll(cache, 0o755)
env = append(env, "W_CACHE="+cache, "WINETRICKS_DOWNLOADER=curl")
cmd := exec.Command(wt, "-q", "arial", "tahoma", "powershell", "win11")
cmd.Env = env
out, err := cmd.CombinedOutput()
a.logf("winetricks %s output=%s", tag, compactDiagnostic(string(out)))
if err != nil {
return fmt.Errorf("Prefix-Komponenten: %s", formatCommandFailure(err, out))
}
runner, _ := a.currentRunner()
wine := filepath.Join(runner, "bin", "wine")
reg := exec.Command(wine, "reg", "add", `HKEY_CURRENT_USER\Software\Wine\FileOpenAssociations`, `/v`, `Enable`, `/d`, `N`, `/f`)
reg.Env = env
out, err = reg.CombinedOutput()
if err != nil {
return fmt.Errorf("Wine Registry: %s", formatCommandFailure(err, out))
}
_ = writeMeta(filepath.Join(a.vendorDir, "winetricks", "meta.json"), componentMeta{Version: tag, Updated: time.Now().Format(time.RFC3339)})
return nil
}
func (a *App) ensureRSILauncher(gc GameConfig, forceIfMissing bool) error {
file, u, sha, err := a.latestRSIInstaller()
if err != nil {
return err
}
metaDir := filepath.Join(a.vendorDir, "rsi")
if err := os.MkdirAll(metaDir, 0o755); err != nil {
return err
}
old := readText(filepath.Join(metaDir, "current.txt"))
_, launcherErr := os.Stat(gc.LauncherEXE)
if launcherErr == nil && old == file && !forceIfMissing {
return nil
}
if launcherErr == nil && old == file {
return nil
}
cache := filepath.Join(a.cacheDir, file)
if _, err := os.Stat(cache); err != nil {
if err := download(u, cache); err != nil {
return err
}
}
if err := verifySHA512Base64(cache, sha); err != nil {
_ = os.Remove(cache)
return err
}
env, runner, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
}
wine := filepath.Join(runner, "bin", "wine")
cmd := exec.Command(wine, cache, "/S")
cmd.Env = env
out, err := cmd.CombinedOutput()
a.logf("RSI installer %s output=%s", file, compactDiagnostic(string(out)))
if err != nil {
return fmt.Errorf("RSI Launcher Installation: %s", formatCommandFailure(err, out))
}
ws := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-k")
ws.Env = env
_, _ = ws.CombinedOutput()
deadline := time.Now().Add(20 * time.Second)
for {
if _, err := os.Stat(gc.LauncherEXE); err == nil {
break
}
if time.Now().After(deadline) {
return fmt.Errorf("RSI Installer beendet, Launcher fehlt: %s", gc.LauncherEXE)
}
time.Sleep(500 * time.Millisecond)
}
if err := os.WriteFile(filepath.Join(metaDir, "current.txt"), []byte(file+"\n"), 0o644); err != nil {
return err
}
return nil
}
func (a *App) writeOwnedLaunchFiles(gc GameConfig) error {
binDir := filepath.Join(a.dataDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
return err
}
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 {
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 {
return err
}
return nil
}
func (a *App) writeLUGCompatibilityConfig(gc GameConfig) error {
dir := filepath.Join(envOr("XDG_CONFIG_HOME", filepath.Join(a.home, ".config")), "starcitizen-lug")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "winedir.conf"), []byte(gc.Prefix+"\n"), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "gamedir.conf"), []byte(gc.GameDir+"\n"), 0o644); err != nil {
return err
}
return nil
}
func (a *App) gameLaunch() error {
gc := a.loadGameConfig()
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.
if cfg := a.loadConfig(); cfg.AutoMaintain {
stale := 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()
}
}
if st.Hardware == "blocked" {
return errors.New(st.HardwareReason)
}
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")
}
env, runner, err := a.runnerEnv(gc.Prefix)
if err != nil {
return err
}
env = append(env,
"__GL_SHADER_DISK_CACHE=1",
"__GL_SHADER_DISK_CACHE_SIZE=10737418240",
"__GL_SHADER_DISK_CACHE_PATH="+gc.Prefix,
"MESA_SHADER_CACHE_DIR="+gc.Prefix,
"MESA_SHADER_CACHE_MAX_SIZE=10G",
)
log := filepath.Join(a.stateDir, "rsi-launcher.log")
_ = os.MkdirAll(a.stateDir, 0o755)
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.Stdout = f
cmd.Stderr = f
if err := cmd.Start(); err != nil {
f.Close()
return err
}
_ = f.Close()
a.logf("RSI Launcher started pid=%d", cmd.Process.Pid)
return nil
}
func (a *App) gameRepair() error {
gc := a.loadGameConfig()
hs := a.hardwareStatus(gc.Prefix)
if hs.State == "blocked" {
return errors.New(hs.Reason)
}
if err := a.syncLUGHelper(); err != nil {
a.logf("LUG runtime warning: %v", err)
}
if err := a.syncWineRunner(); err != nil {
return err
}
if !prefixInitialized(gc.Prefix) {
return a.gameInstall()
}
if err := a.ensurePrefixComponents(gc); err != nil {
return err
}
if err := a.ensureRSILauncher(gc, false); err != nil {
return err
}
if err := a.writeOwnedLaunchFiles(gc); err != nil {
return err
}
gc.LastRepair = time.Now().Format(time.RFC3339)
_ = a.saveGameConfig(gc)
return nil
}
func (a *App) syncRSILauncherIfNeeded() error {
gc := a.loadGameConfig()
if !prefixInitialized(gc.Prefix) || prefixBusy(gc.Prefix) {
return nil
}
return a.ensureRSILauncher(gc, false)
}
func readText(path string) string { b, _ := os.ReadFile(path); return strings.TrimSpace(string(b)) }
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" }
// Keep sort referenced here because installation diagnostics intentionally sort paths in future additions.
var _ = sort.Strings
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"regexp"
"strings"
"time"
)
func (a *App) createSupportBundle() (string, error) {
stamp := time.Now().Format("20060102-150405")
id := fmt.Sprintf("CL-%s-%d", stamp, os.Getpid())
downloads := filepath.Join(a.home, "Downloads")
if out, err := exec.Command("xdg-user-dir", "DOWNLOAD").Output(); err == nil && strings.TrimSpace(string(out)) != "" {
downloads = strings.TrimSpace(string(out))
}
if err := os.MkdirAll(downloads, 0o755); err != nil {
return "", err
}
target := filepath.Join(downloads, "Citizen-Launcher-Support-"+id+".tar.gz")
f, err := os.Create(target)
if err != nil {
return "", err
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
closeAll := func() { _ = tw.Close(); _ = gz.Close(); _ = f.Close() }
defer closeAll()
add := func(name string, data []byte) error {
data = sanitizeSupport(data, a.home)
h := &tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: time.Now()}
if err := tw.WriteHeader(h); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
addJSON := func(name string, v any) {
b, _ := json.MarshalIndent(v, "", " ")
b = append(b, '\n')
_ = add(name, b)
}
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"))
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"},
} {
if _, err := exec.LookPath(cmd[0]); err != nil {
continue
}
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"),
} {
if b, err := tailFile(path, 700000); err == nil {
_ = add(name, b)
}
}
return target, nil
}
func tailFile(path string, max int64) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return nil, err
}
off := int64(0)
if st.Size() > max {
off = st.Size() - max
}
if _, err = f.Seek(off, 0); err != nil {
return nil, err
}
var b bytes.Buffer
_, err = b.ReadFrom(f)
return b.Bytes(), err
}
func sanitizeSupport(in []byte, home string) []byte {
s := string(in)
if home != "" {
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>")
}
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>"},
}
for _, p := range patterns {
s = regexp.MustCompile(p.re).ReplaceAllString(s, p.repl)
}
return []byte(s)
}
+1
View File
@@ -0,0 +1 @@
const B=window.CITIZEN_BASE;let current=null;const $=id=>document.getElementById(id);async function api(path,opts){const r=await fetch(B+path,opts);if(!r.ok)throw new Error(await r.text());return r.json()}function cls(el,state){el.classList.remove('good','bad','warn');if(state)el.classList.add(state)}function txt(id,v,c){const e=$(id);e.textContent=v||'—';cls(e,c)}function primaryFor(h){if(h==='ready')return['STAR CITIZEN STARTEN','launch'];if(h==='hardware-blocked')return['HARDWARE-DETAILS','doctor'];if(h==='repair'||h==='launcher-repair')return['AUTOMATISCH REPARIEREN','repair'];return['EINRICHTEN & STARTKLAR MACHEN','setup']}async function refresh(){try{const s=await api('/api/status');current=s;$('version').textContent='v'+s.version;$('platform').textContent=[s.platform.name,s.platform.desktop].filter(Boolean).join(' · ');$('distro').textContent=s.platform.name;$('session').textContent=s.platform.session||'—';const g=s.game;let head='READY FOR SETUP',sub='Citizen Launcher übernimmt Wine, DXVK, Prefix und RSI Launcher.';if(g.health==='ready'){head='FLIGHT READY';sub='Star Citizen ist startbereit. Autopilot hält den Gaming-Stack aktuell.'}else if(g.health==='hardware-blocked'){head='HARDWARE CHECK';sub=g.hardware_reason||'Die Hardware erfüllt eine Voraussetzung nicht.'}else if(g.health==='repair'||g.health==='launcher-repair'){head='REPAIR AVAILABLE';sub='Ein reparierbarer Zustand wurde erkannt.'}$('headline').textContent=head;$('subline').textContent=sub;const [label,act]=primaryFor(g.health);$('primary').textContent=label;$('primary').dataset.action=act;txt('gpu',g.gpu||'nicht eindeutig erkannt',g.hardware==='blocked'?'bad':'good');txt('vulkan',g.vulkan,g.vulkan==='blocked'?'bad':g.vulkan==='ready'?'good':'warn');txt('ram',g.ram_gib+' GiB',g.ram_gib>=16?'good':'warn');txt('wine',g.wine_version||'wird eingerichtet',g.wine_version?'good':'warn');txt('dxvk',g.dxvk_version||'wird eingerichtet',g.dxvk_version?'good':'warn');txt('rsi',g.launcher_state==='ready'?'bereit':'Setup',g.launcher_state==='ready'?'good':'warn');txt('autopilot',g.autopilot?'aktiv':'aus',g.autopilot?'good':'warn');$('raw').textContent=JSON.stringify(s,null,2)}catch(e){$('headline').textContent='BACKEND ERROR';$('subline').textContent=e.message}}async function action(name){try{if(name==='launch'){await api('/api/action/launch',{method:'POST'});return}const j=await api('/api/action/'+name,{method:'POST'});$('job').classList.remove('hidden');$('jobTitle').textContent='Aktion: '+name;$('jobText').textContent='läuft …';poll(j.id)}catch(e){$('job').classList.remove('hidden');$('jobTitle').textContent='Fehler';$('jobText').textContent=e.message}}async function poll(id){try{const j=await api('/api/job/'+id);if(j.state==='running'){setTimeout(()=>poll(id),1000);return}$('jobText').textContent=j.state==='done'?(j.message||'Abgeschlossen'):j.error;$('job').querySelector('.spinner').classList.toggle('hidden',j.state!=='running');await refresh()}catch(e){$('jobText').textContent=e.message}}document.addEventListener('click',e=>{const a=e.target.dataset.action;if(a)action(a);const o=e.target.dataset.open;if(o)api('/api/open/'+o,{method:'POST'});});$('toggle').onclick=()=>$('advanced').classList.toggle('hidden');$('primary').onclick=()=>action($('primary').dataset.action);refresh();setInterval(refresh,10000);
@@ -0,0 +1,16 @@
<!doctype html>
<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><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>
<section class="hero" id="hero"><div><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="grid three">
<article><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><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><h3>Autopilot</h3><div class="metric"><span>Wartung</span><b id="autopilot">—</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">Gaming-Stack aktualisieren</button><button data-action="repair">Problem automatisch beheben</button><button data-action="doctor">Wine-Selbsttest</button><button data-action="support">Support-Paket erstellen</button></section>
<section class="job hidden" id="job"><div class="spinner"></div><div><b id="jobTitle">Aktion läuft</b><p id="jobText"></p></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 Star Citizen im Benutzerkonto. System-/GPU-Treiber bleiben Aufgabe der Distribution.</footer>
</main><script>window.CITIZEN_BASE="__BASE__";</script><script src="__BASE__/assets/app.js"></script></body></html>
@@ -0,0 +1 @@
:root{color-scheme:dark;--bg:#070d13;--panel:#0b1822;--panel2:#0f2230;--line:#1d526d;--cyan:#55d8ff;--green:#75e9b1;--amber:#ffd275;--red:#ff879c;--text:#e8f5fb;--muted:#8da7b5}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 70% -20%,#123047 0,transparent 42%),linear-gradient(150deg,#050a10,#09131c 60%,#06101a);color:var(--text);font:15px Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}.scan{position:fixed;inset:0;pointer-events:none;opacity:.12;background:repeating-linear-gradient(0deg,transparent 0 3px,#66dbff10 4px)}main{width:min(1180px,calc(100% - 32px));margin:28px auto 48px}header{display:flex;align-items:center;gap:16px;padding:0 4px 20px}.mark{width:54px;height:54px;display:grid;place-items:center;border:1px solid var(--cyan);border-radius:14px;color:var(--cyan);font-size:25px;box-shadow:0 0 25px #4bd5ff22 inset}.eyebrow{font-size:11px;letter-spacing:.18em;color:var(--cyan);font-weight:800}h1,h2,h3,p{margin:0}h1{font-size:25px;letter-spacing:.02em}header p{color:var(--muted);margin-top:3px}.version{margin-left:auto;color:var(--muted);font-family:ui-monospace,monospace}.hero,.grid article,.job,.details{border:1px solid #1c4053;border-radius:20px;background:linear-gradient(155deg,#0c1b26e8,#09131be8);box-shadow:0 18px 60px #0007}.hero{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:28px;border-color:#2c7798}.hero h2{font-size:35px;letter-spacing:.04em;margin:5px 0 7px}.hero p{color:var(--muted);max-width:690px}.primary{padding:17px 28px!important;font-weight:900;letter-spacing:.08em;border-color:var(--cyan)!important;color:#08141c!important;background:linear-gradient(135deg,#7de4ff,#45c8f7)!important;min-width:270px}.grid{display:grid;gap:14px;margin-top:14px}.three{grid-template-columns:repeat(3,1fr)}article{padding:20px}article h3{color:var(--cyan);font-size:13px;letter-spacing:.12em;text-transform:uppercase;margin-bottom:12px}.metric{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #153345}.metric:last-child{border:0}.metric span{color:var(--muted)}.metric b{text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:65%}.actions{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:14px 0}button{border:1px solid #2d5368;background:#0d1b25;color:var(--text);padding:13px 16px;border-radius:10px;cursor:pointer;font:inherit;transition:.15s}button:hover{border-color:var(--cyan);background:#102737}.job{display:flex;align-items:center;gap:14px;padding:16px;margin:14px 0}.job p{color:var(--muted);margin-top:3px}.spinner{width:22px;height:22px;border:2px solid #285165;border-top-color:var(--cyan);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.details{padding:12px 14px}.details>button{width:100%;background:transparent;border:0;color:var(--muted)}pre{white-space:pre-wrap;color:#a9c7d4;background:#061018;padding:14px;border-radius:12px;max-height:330px;overflow:auto;font-size:12px}.small-actions{display:flex;gap:8px}.hidden{display:none!important}.bad{color:var(--red)!important}.warn{color:var(--amber)!important}.good{color:var(--green)!important}footer{text-align:center;color:#597584;font-size:12px;padding:22px}@media(max-width:850px){.three,.actions{grid-template-columns:1fr}.hero{align-items:stretch;flex-direction:column}.primary{width:100%}}