857 lines
36 KiB
Python
857 lines
36 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
BeamMP GUI Setup für BeamNG.drive (Proton)
|
||
- lädt BeamMP-Launcher.exe von GitHub herunter
|
||
- installiert ihn in das BeamNG-Protonprefix (compatdata/284160)
|
||
- trägt BeamMP als Non-Steam-Game in Steam ein (inkl. Proton-CompatToolMapping)
|
||
|
||
Getestet auf Gentoo, Steam-Layout mit mehreren Bibliotheken (/mnt/Games + ~/.local/share/Steam).
|
||
Benötigt: python3 + PyGObject (Gtk 3). Kein tkinter nötig.
|
||
Start: ~/scripts/beammp-gui.py oder python3 ~/scripts/beammp-gui.py
|
||
"""
|
||
import gi
|
||
gi.require_version("Gtk", "3.0")
|
||
from gi.repository import Gtk, GLib
|
||
import os, re, sys, shutil, struct, binascii, json
|
||
import urllib.request, threading, subprocess, time
|
||
|
||
BEAMNG_APPID = "284160"
|
||
# Windows-Launcher (funktioniert im Proton-Prefix). Quelle: GitHub Releases.
|
||
BEAMMP_EXE_URL = "https://github.com/BeamMP/BeamMP-Launcher/releases/latest/download/BeamMP-Launcher.exe"
|
||
BEAMMP_FALLBACK_URL = "https://beammp.com/installer/BeamMP_Installer.zip"
|
||
CONFIG_FILE = os.path.expanduser("~/.config/beammp-gui/config.json")
|
||
WRAPPER_NAME = "beammp-launch.sh"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Steam / BeamNG Erkennung
|
||
# --------------------------------------------------------------------------
|
||
|
||
def find_steam_root():
|
||
candidates = [
|
||
os.path.expanduser("~/.local/share/Steam"),
|
||
os.path.expanduser("~/.steam/steam"),
|
||
os.path.expanduser("~/.steam/root"),
|
||
]
|
||
for p in candidates:
|
||
if os.path.isfile(os.path.join(p, "steamapps", "libraryfolders.vdf")):
|
||
return p
|
||
if os.path.isdir(os.path.join(p, "steamapps")):
|
||
return p
|
||
return None
|
||
|
||
|
||
def parse_libraryfolders(steam_root):
|
||
"""Liest libraryfolders.vdf und gibt Liste von Bibliotheks-Pfaden zurück."""
|
||
libs = []
|
||
if steam_root and os.path.isdir(steam_root):
|
||
libs.append(steam_root)
|
||
lf = os.path.join(steam_root or "", "steamapps", "libraryfolders.vdf")
|
||
if os.path.isfile(lf):
|
||
try:
|
||
with open(lf, errors="replace") as f:
|
||
for line in f:
|
||
m = re.search(r'"path"\s+"([^"]+)"', line)
|
||
if m:
|
||
p = m.group(1).replace("\\\\", "/").replace("\\", "/")
|
||
# Steam escapt ggf. doppelte Slashes
|
||
p = os.path.normpath(p)
|
||
if os.path.isdir(p) and p not in libs:
|
||
libs.append(p)
|
||
except Exception:
|
||
pass
|
||
# Deduplizieren, existierende zuerst
|
||
out = []
|
||
for p in libs:
|
||
if p not in out:
|
||
out.append(p)
|
||
return out
|
||
|
||
|
||
def parse_acf_installdir(acf_path):
|
||
try:
|
||
with open(acf_path, errors="replace") as f:
|
||
for line in f:
|
||
m = re.search(r'"installdir"\s+"([^"]+)"', line)
|
||
if m:
|
||
return m.group(1)
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def find_beamng():
|
||
"""
|
||
Gibt dict zurück: {game_dir, library, prefix, compatdata}
|
||
prefix = .../compatdata/284160/pfx
|
||
compatdata = .../compatdata/284160
|
||
"""
|
||
steam = find_steam_root()
|
||
if not steam:
|
||
return None
|
||
libs = parse_libraryfolders(steam)
|
||
# 1. per appmanifest suchen (korrekte AppID 284160!)
|
||
for lib in libs:
|
||
acf = os.path.join(lib, "steamapps", f"appmanifest_{BEAMNG_APPID}.acf")
|
||
if os.path.isfile(acf):
|
||
installdir = parse_acf_installdir(acf) or "BeamNG.drive"
|
||
game_dir = os.path.join(lib, "steamapps", "common", installdir)
|
||
if os.path.isdir(game_dir):
|
||
compat = os.path.join(lib, "steamapps", "compatdata", BEAMNG_APPID)
|
||
prefix = os.path.join(compat, "pfx")
|
||
return {"game_dir": game_dir, "library": lib,
|
||
"compatdata": compat, "prefix": prefix, "steam": steam}
|
||
# 2. Fallback: common/BeamNG.drive Verzeichnis-Scan
|
||
for lib in libs:
|
||
gd = os.path.join(lib, "steamapps", "common", "BeamNG.drive")
|
||
if os.path.isdir(gd) and os.path.isfile(os.path.join(gd, "BeamNG.drive.exe")):
|
||
compat = os.path.join(lib, "steamapps", "compatdata", BEAMNG_APPID)
|
||
return {"game_dir": gd, "library": lib,
|
||
"compatdata": compat, "prefix": os.path.join(compat, "pfx"),
|
||
"steam": steam}
|
||
return None
|
||
|
||
|
||
def find_proton_versions():
|
||
"""Liste von (name, proton_binary, tool_dir)."""
|
||
steam = find_steam_root()
|
||
if not steam:
|
||
return []
|
||
libs = parse_libraryfolders(steam)
|
||
search_bases = []
|
||
for lib in libs:
|
||
search_bases.append(os.path.join(lib, "steamapps", "common"))
|
||
search_bases.append(os.path.join(lib, "compatibilitytools.d"))
|
||
# zusätzlich user-weite tools
|
||
search_bases.append(os.path.expanduser("~/.local/share/Steam/compatibilitytools.d"))
|
||
search_bases.append(os.path.join(steam, "compatibilitytools.d"))
|
||
found = {}
|
||
for base in search_bases:
|
||
if not os.path.isdir(base):
|
||
continue
|
||
try:
|
||
for name in sorted(os.listdir(base)):
|
||
low = name.lower()
|
||
if "proton" not in low and "ge-" not in low and "wine" not in low:
|
||
continue
|
||
tool_dir = os.path.join(base, name)
|
||
proton_bin = os.path.join(tool_dir, "proton")
|
||
if os.path.isfile(proton_bin) and name not in found:
|
||
found[name] = (proton_bin, tool_dir)
|
||
except Exception:
|
||
continue
|
||
return [(n, pb, td) for n, (pb, td) in sorted(found.items())]
|
||
|
||
|
||
def proton_to_compat_name(folder_name):
|
||
low = folder_name.lower()
|
||
if "experimental" in low:
|
||
return "proton_experimental"
|
||
m = re.search(r"proton\s*0*(\d+)", low)
|
||
if m:
|
||
return f"proton_{m.group(1)}"
|
||
if "ge-proton" in low:
|
||
# GE-Proton: Steam nutzt internen Namen, Ordnername funktioniert meist nicht.
|
||
# Fallback auf proton_9 + Hinweis; Mapping wird trotzdem gesetzt.
|
||
mm = re.search(r"(\d+)", low)
|
||
if mm:
|
||
return f"proton_{mm.group(1)}"
|
||
return "proton_9"
|
||
if "hotfix" in low:
|
||
return "proton_hotfix"
|
||
return "proton_9"
|
||
|
||
|
||
def steam_running():
|
||
# steam.pid existiert + Prozess läuft, oder pgrep steam
|
||
for cmd in (["pgrep", "-x", "steam"], ["pgrep", "-x", "steamwebhelper"]):
|
||
try:
|
||
r = subprocess.run(cmd, capture_output=True, timeout=5)
|
||
if r.returncode == 0 and r.stdout.strip():
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# shortcuts.vdf (binär) lesen/schreiben – typ-erhaltend!
|
||
# Typen: 0x00 = Objekt, 0x01 = String, 0x02 = int32, 0x08 = Ende
|
||
# --------------------------------------------------------------------------
|
||
|
||
def _read_cstr(data, pos):
|
||
end = data.index(b"\x00", pos)
|
||
s = data[pos:end].decode("utf-8", errors="replace")
|
||
return s, end + 1
|
||
|
||
|
||
def _parse_obj(data, pos):
|
||
"""Parst Objekt-Inhalt ab pos, gibt (dict, neue_pos) zurück. End-Byte 0x08 wird konsumiert."""
|
||
obj = {}
|
||
order = [] # Key-Reihenfolge + Typ merken
|
||
n = len(data)
|
||
while pos < n:
|
||
t = data[pos]
|
||
pos += 1
|
||
if t == 0x08:
|
||
break
|
||
key, pos = _read_cstr(data, pos)
|
||
if t == 0x01:
|
||
val, pos = _read_cstr(data, pos)
|
||
obj[key] = val
|
||
order.append((key, 0x01))
|
||
elif t == 0x02:
|
||
val = struct.unpack_from("<i", data, pos)[0]
|
||
pos += 4
|
||
obj[key] = val
|
||
order.append((key, 0x02))
|
||
elif t == 0x00:
|
||
val, pos = _parse_obj(data, pos)
|
||
obj[key] = val
|
||
order.append((key, 0x00))
|
||
else:
|
||
raise ValueError(f"Unbekannter VDF-Typ {t:#x} bei Key {key!r}")
|
||
obj["_order"] = order
|
||
return obj, pos
|
||
|
||
|
||
def read_shortcuts_vdf(path):
|
||
"""Gibt (top_dict, order_list) zurück. top_dict: {'0': {...}, '1': {...}}"""
|
||
with open(path, "rb") as f:
|
||
data = f.read()
|
||
if not data:
|
||
return {}, []
|
||
pos = 0
|
||
if data[pos] != 0x00:
|
||
raise ValueError("Kein gültiges shortcuts.vdf (erwartet 0x00 am Anfang)")
|
||
pos += 1
|
||
root_key, pos = _read_cstr(data, pos) # "shortcuts"
|
||
top, pos = _parse_obj(data, pos)
|
||
order = top.pop("_order", [])
|
||
for v in top.values():
|
||
if isinstance(v, dict):
|
||
v.pop("_order", None)
|
||
keys = [k for k, _ in order]
|
||
return top, keys
|
||
|
||
|
||
def _write_cstr(f, s):
|
||
f.write(s.encode("utf-8", errors="replace") + b"\x00")
|
||
|
||
|
||
def _write_obj(f, obj, order=None):
|
||
items = order if order else [(k, None) for k in obj.keys() if not k.startswith("_")]
|
||
seen = set()
|
||
for key, forced_t in items:
|
||
if key.startswith("_") or key not in obj:
|
||
continue
|
||
seen.add(key)
|
||
val = obj[key]
|
||
if isinstance(val, dict):
|
||
f.write(b"\x00")
|
||
_write_cstr(f, key)
|
||
sub_order = val.get("_order")
|
||
_write_obj(f, {k: v for k, v in val.items() if not k.startswith("_")}, sub_order)
|
||
elif isinstance(val, int) and not isinstance(val, bool):
|
||
f.write(b"\x02")
|
||
_write_cstr(f, key)
|
||
f.write(struct.pack("<i", val))
|
||
else:
|
||
f.write(b"\x01")
|
||
_write_cstr(f, key)
|
||
_write_cstr(f, str(val))
|
||
for key, val in obj.items():
|
||
if key.startswith("_") or key in seen:
|
||
continue
|
||
if isinstance(val, dict):
|
||
f.write(b"\x00")
|
||
_write_cstr(f, key)
|
||
_write_obj(f, {k: v for k, v in val.items() if not k.startswith("_")}, val.get("_order"))
|
||
elif isinstance(val, int) and not isinstance(val, bool):
|
||
f.write(b"\x02")
|
||
_write_cstr(f, key)
|
||
f.write(struct.pack("<i", val))
|
||
else:
|
||
f.write(b"\x01")
|
||
_write_cstr(f, key)
|
||
_write_cstr(f, str(val))
|
||
f.write(b"\x08")
|
||
|
||
|
||
def write_shortcuts_vdf(path, top, keys):
|
||
tmp = path + ".tmp"
|
||
with open(tmp, "wb") as f:
|
||
f.write(b"\x00")
|
||
_write_cstr(f, "shortcuts")
|
||
# Einträge in numerischer Reihenfolge
|
||
for i, k in enumerate(keys):
|
||
entry = top[k]
|
||
f.write(b"\x00")
|
||
_write_cstr(f, str(i))
|
||
_write_obj(f, entry)
|
||
f.write(b"\x08\x08") # Objekt-Ende + Root-Terminator (wie Steam selbst)
|
||
os.replace(tmp, path)
|
||
|
||
|
||
def steam_shortcut_appid(exe, appname):
|
||
"""Standard Steam-Algorithmus: crc32(exe + appname) | 0x80000000 als signed int32."""
|
||
crc = binascii.crc32((exe + appname).encode("utf-8")) & 0xFFFFFFFF
|
||
crc |= 0x80000000
|
||
return struct.unpack("<i", struct.pack("<I", crc))[0]
|
||
|
||
|
||
def ensure_compat_mapping(config_vdf_path, unsigned_id, tool_name, priority="250"):
|
||
"""Trägt Proton-Mapping in config.vdf ein (Text-VDF). Backup wird erstellt."""
|
||
with open(config_vdf_path, errors="replace") as f:
|
||
text = f.read()
|
||
key = str(unsigned_id)
|
||
# Bereits vorhanden? Dann Namen ersetzen.
|
||
pat = re.compile(r'"' + re.escape(key) + r'"\s*\{\s*"name"\s*"[^"]*"\s*"config"\s*"[^"]*"\s*"priority"\s*"[^"]*"\s*\}', re.DOTALL)
|
||
block = (f'\t\t\t\t\t"{key}"\n\t\t\t\t\t{{\n'
|
||
f'\t\t\t\t\t\t"name"\t\t"{tool_name}"\n'
|
||
f'\t\t\t\t\t\t"config"\t\t""\n'
|
||
f'\t\t\t\t\t\t"priority"\t\t"{priority}"\n\t\t\t\t\t}}')
|
||
if pat.search(text):
|
||
text = pat.sub(block, text)
|
||
else:
|
||
# Vor der schließenden Klammer von "CompatToolMapping" einfügen:
|
||
# Suche den Block und füge vor dem letzten "}," auf derselben Ebene ein.
|
||
m = re.search(r'"CompatToolMapping"\s*\n\s*\{', text)
|
||
if not m:
|
||
raise ValueError("CompatToolMapping-Block in config.vdf nicht gefunden")
|
||
# Finde zugehörige schließende Klammer per Klammerzähler ab m.end()
|
||
depth = 1
|
||
i = m.end()
|
||
in_str = False
|
||
esc = False
|
||
while i < len(text) and depth > 0:
|
||
c = text[i]
|
||
if in_str:
|
||
if esc:
|
||
esc = False
|
||
elif c == "\\":
|
||
esc = True
|
||
elif c == '"':
|
||
in_str = False
|
||
else:
|
||
if c == '"':
|
||
in_str = True
|
||
elif c == "{":
|
||
depth += 1
|
||
elif c == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
break
|
||
i += 1
|
||
if depth != 0:
|
||
raise ValueError("config.vdf Klammern unausgeglichen")
|
||
text = text[:i] + "\n" + block + "\n\t\t\t\t" + text[i:]
|
||
backup = config_vdf_path + ".beammp-gui.bak"
|
||
shutil.copy2(config_vdf_path, backup)
|
||
with open(config_vdf_path, "w") as f:
|
||
f.write(text)
|
||
return backup
|
||
|
||
|
||
def remove_compat_mapping(config_vdf_path, unsigned_id):
|
||
"""Entfernt Proton-Mapping wieder (Wrapper läuft nativ). Gibt True zurück wenn etwas entfernt wurde."""
|
||
with open(config_vdf_path, errors="replace") as f:
|
||
text = f.read()
|
||
key = str(unsigned_id)
|
||
pat = re.compile(r'\s*"' + re.escape(key) + r'"\s*\{\s*"name"\s*"[^"]*"\s*"config"\s*"[^"]*"\s*"priority"\s*"[^"]*"\s*\}', re.DOTALL)
|
||
if not pat.search(text):
|
||
return False
|
||
text = pat.sub("", text, count=1)
|
||
backup = config_vdf_path + ".beammp-gui.bak"
|
||
shutil.copy2(config_vdf_path, backup)
|
||
with open(config_vdf_path, "w") as f:
|
||
f.write(text)
|
||
return True
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Download / Installation
|
||
# --------------------------------------------------------------------------
|
||
|
||
def download_file(url, dest, log, progress_cb=None):
|
||
req = urllib.request.Request(url, headers={"User-Agent": "beammp-gui/1.0"})
|
||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||
total = int(resp.headers.get("Content-Length") or 0)
|
||
got = 0
|
||
chunk = 1024 * 256
|
||
with open(dest, "wb") as f:
|
||
while True:
|
||
buf = resp.read(chunk)
|
||
if not buf:
|
||
break
|
||
f.write(buf)
|
||
got += len(buf)
|
||
if progress_cb:
|
||
GLib.idle_add(progress_cb, got, total)
|
||
return dest
|
||
|
||
|
||
def official_launcher_dest(prefix):
|
||
"""Offizieller Ablageort im Prefix (wie Windows %AppData%)."""
|
||
return os.path.join(prefix, "drive_c", "users", "steamuser",
|
||
"AppData", "Roaming", "BeamMP-Launcher", "BeamMP-Launcher.exe")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# GUI
|
||
# --------------------------------------------------------------------------
|
||
|
||
class MainWindow(Gtk.ApplicationWindow):
|
||
def __init__(self, app):
|
||
super().__init__(application=app, title="BeamMP für BeamNG (Proton) – Setup")
|
||
self.set_default_size(760, 660)
|
||
self.set_border_width(10)
|
||
self.config = self._load_config()
|
||
self.beamng = None
|
||
self._build_ui()
|
||
GLib.idle_add(self.refresh)
|
||
|
||
# -- config --
|
||
def _load_config(self):
|
||
try:
|
||
if os.path.isfile(CONFIG_FILE):
|
||
with open(CONFIG_FILE) as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
def _save_config(self):
|
||
try:
|
||
self.config.update({
|
||
"launcher_path": self.ent_launcher.get_text(),
|
||
"steam_name": self.ent_name.get_text(),
|
||
"proton": self.cmb_proton.get_active_text() or "",
|
||
})
|
||
os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
|
||
with open(CONFIG_FILE, "w") as f:
|
||
json.dump(self.config, f, indent=2)
|
||
except Exception:
|
||
pass
|
||
|
||
# -- UI --
|
||
def _build_ui(self):
|
||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||
self.add(vbox)
|
||
|
||
# Status
|
||
fr = Gtk.Frame(label=" Erkennung ")
|
||
vbox.pack_start(fr, False, False, 0)
|
||
sb = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||
sb.set_border_width(6)
|
||
fr.add(sb)
|
||
self.lbl_steam = Gtk.Label(xalign=0, selectable=True)
|
||
self.lbl_beamng = Gtk.Label(xalign=0, selectable=True)
|
||
self.lbl_prefix = Gtk.Label(xalign=0, selectable=True)
|
||
self.lbl_proton = Gtk.Label(xalign=0, selectable=True)
|
||
for lab in (self.lbl_steam, self.lbl_beamng, self.lbl_prefix, self.lbl_proton):
|
||
lab.set_line_wrap(True)
|
||
sb.pack_start(lab, False, False, 1)
|
||
|
||
# Einstellungen
|
||
fr2 = Gtk.Frame(label=" Einstellungen ")
|
||
vbox.pack_start(fr2, False, False, 0)
|
||
grid = Gtk.Grid(column_spacing=8, row_spacing=6)
|
||
grid.set_border_width(8)
|
||
fr2.add(grid)
|
||
|
||
grid.attach(Gtk.Label(label="BeamMP-Launcher (.exe):", xalign=0), 0, 0, 1, 1)
|
||
self.ent_launcher = Gtk.Entry(hexpand=True)
|
||
self.ent_launcher.set_text(self.config.get("launcher_path",
|
||
os.path.expanduser("~/Downloads/BeamMP-Launcher.exe")))
|
||
self.ent_launcher.set_tooltip_text("Pfad zur heruntergeladenen BeamMP-Launcher.exe")
|
||
grid.attach(self.ent_launcher, 1, 0, 1, 1)
|
||
hb = Gtk.Box(spacing=4)
|
||
b_dl = Gtk.Button(label="Herunterladen")
|
||
b_dl.connect("clicked", self.on_download)
|
||
b_br = Gtk.Button(label="...")
|
||
b_br.set_size_request(36, -1)
|
||
b_br.connect("clicked", self.on_browse_exe)
|
||
hb.pack_start(b_dl, False, False, 0)
|
||
hb.pack_start(b_br, False, False, 0)
|
||
grid.attach(hb, 2, 0, 1, 1)
|
||
|
||
grid.attach(Gtk.Label(label="Proton-Version:", xalign=0), 0, 1, 1, 1)
|
||
self.cmb_proton = Gtk.ComboBoxText(hexpand=True)
|
||
grid.attach(self.cmb_proton, 1, 1, 1, 1)
|
||
b_rf = Gtk.Button(label="Aktualisieren")
|
||
b_rf.connect("clicked", lambda *_: self.refresh())
|
||
grid.attach(b_rf, 2, 1, 1, 1)
|
||
|
||
grid.attach(Gtk.Label(label="Name in Steam:", xalign=0), 0, 2, 1, 1)
|
||
self.ent_name = Gtk.Entry(hexpand=True)
|
||
self.ent_name.set_text(self.config.get("steam_name", "BeamMP"))
|
||
grid.attach(self.ent_name, 1, 2, 2, 1)
|
||
|
||
self.progress = Gtk.ProgressBar(show_text=True)
|
||
self.progress.set_text("bereit")
|
||
grid.attach(self.progress, 0, 3, 3, 1)
|
||
|
||
# Aktionen
|
||
fr3 = Gtk.Frame(label=" Aktionen (Reihenfolge einhalten) ")
|
||
vbox.pack_start(fr3, False, False, 0)
|
||
hbox = Gtk.Box(spacing=6)
|
||
hbox.set_border_width(8)
|
||
fr3.add(hbox)
|
||
b1 = Gtk.Button(label="1. Im Prefix installieren")
|
||
b1.get_style_context().add_class("suggested-action")
|
||
b1.connect("clicked", self.on_install)
|
||
b2 = Gtk.Button(label="2. Zu Steam hinzufügen")
|
||
b2.get_style_context().add_class("suggested-action")
|
||
b2.connect("clicked", self.on_add_steam)
|
||
b_all = Gtk.Button(label="Alles nacheinander")
|
||
b_all.connect("clicked", self.on_all)
|
||
for b in (b1, b2, b_all):
|
||
hbox.pack_start(b, True, True, 0)
|
||
|
||
# Log
|
||
fr4 = Gtk.Frame(label=" Log ")
|
||
vbox.pack_start(fr4, True, True, 0)
|
||
sw = Gtk.ScrolledWindow()
|
||
sw.set_min_content_height(220)
|
||
fr4.add(sw)
|
||
self.log_buf = Gtk.TextBuffer()
|
||
self.log_view = Gtk.TextView(buffer=self.log_buf)
|
||
self.log_view.set_editable(False)
|
||
self.log_view.set_monospace(True)
|
||
self.log_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
||
sw.add(self.log_view)
|
||
self.show_all()
|
||
|
||
# -- helpers --
|
||
def log(self, msg):
|
||
def do():
|
||
it = self.log_buf.get_end_iter()
|
||
self.log_buf.insert(it, f"[{time.strftime('%H:%M:%S')}] {msg}\n")
|
||
mark = self.log_buf.create_mark(None, self.log_buf.get_end_iter(), False)
|
||
self.log_view.scroll_mark_onscreen(mark)
|
||
GLib.idle_add(do)
|
||
print(msg, flush=True)
|
||
|
||
def refresh(self):
|
||
steam = find_steam_root()
|
||
if steam:
|
||
self.lbl_steam.set_markup(f'<b>Steam:</b> <span fgcolor="green">OK</span> {GLib.markup_escape_text(steam)}')
|
||
else:
|
||
self.lbl_steam.set_markup('<b>Steam:</b> <span fgcolor="red">nicht gefunden</span>')
|
||
|
||
self.beamng = find_beamng()
|
||
if self.beamng:
|
||
self.lbl_beamng.set_markup(f'<b>BeamNG:</b> <span fgcolor="green">OK</span> {GLib.markup_escape_text(self.beamng["game_dir"])}')
|
||
pfx = self.beamng["prefix"]
|
||
if os.path.isdir(os.path.join(pfx, "drive_c")):
|
||
self.lbl_prefix.set_markup(f'<b>Prefix (284160):</b> <span fgcolor="green">OK</span> {GLib.markup_escape_text(pfx)}')
|
||
else:
|
||
self.lbl_prefix.set_markup(f'<b>Prefix (284160):</b> <span fgcolor="orange">noch nicht von Steam erstellt – bitte BeamNG einmal starten</span> {GLib.markup_escape_text(pfx)}')
|
||
else:
|
||
self.lbl_beamng.set_markup('<b>BeamNG:</b> <span fgcolor="red">nicht gefunden (AppID 284160 – bitte in Steam installieren)</span>')
|
||
self.lbl_prefix.set_markup('<b>Prefix:</b> <span fgcolor="red">–</span>')
|
||
|
||
pvs = find_proton_versions()
|
||
self.cmb_proton.remove_all()
|
||
if pvs:
|
||
self.lbl_proton.set_markup(f'<b>Proton:</b> <span fgcolor="green">{len(pvs)} gefunden</span>')
|
||
for name, _pb, _td in pvs:
|
||
self.cmb_proton.append_text(name)
|
||
saved = self.config.get("proton", "")
|
||
names = [n for n, _, _ in pvs]
|
||
self.cmb_proton.set_active(names.index(saved) if saved in names else 0)
|
||
else:
|
||
self.lbl_proton.set_markup('<b>Proton:</b> <span fgcolor="red">keins gefunden – bitte z.B. Proton 9 in Steam installieren</span>')
|
||
|
||
def _selected_proton(self):
|
||
name = self.cmb_proton.get_active_text()
|
||
if not name:
|
||
return None
|
||
for n, pb, td in find_proton_versions():
|
||
if n == name:
|
||
return (n, pb, td)
|
||
return None
|
||
|
||
# -- Aktionen --
|
||
def on_browse_exe(self, _):
|
||
dlg = Gtk.FileChooserDialog(title="BeamMP-Launcher.exe wählen",
|
||
action=Gtk.FileChooserAction.OPEN)
|
||
dlg.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
|
||
dlg.add_button(Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
|
||
flt = Gtk.FileFilter()
|
||
flt.set_name("Windows-Executables")
|
||
flt.add_pattern("*.exe")
|
||
dlg.add_filter(flt)
|
||
if dlg.run() == Gtk.ResponseType.OK:
|
||
self.ent_launcher.set_text(dlg.get_filename())
|
||
dlg.destroy()
|
||
|
||
def on_download(self, _):
|
||
dest = self.ent_launcher.get_text().strip() or os.path.expanduser("~/Downloads/BeamMP-Launcher.exe")
|
||
if os.path.isdir(dest):
|
||
dest = os.path.join(dest, "BeamMP-Launcher.exe")
|
||
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
|
||
self.ent_launcher.set_text(dest)
|
||
self._save_config()
|
||
self.log(f"Download von {BEAMMP_EXE_URL} nach {dest} …")
|
||
|
||
def work():
|
||
try:
|
||
def prog(got, total):
|
||
if total:
|
||
self.progress.set_fraction(min(got / total, 1.0))
|
||
self.progress.set_text(f"{got//1024//1024} / {total//1024//1024} MB")
|
||
return False
|
||
try:
|
||
download_file(BEAMMP_EXE_URL, dest, self.log, prog)
|
||
except Exception as e1:
|
||
self.log(f"Primär-URL fehlgeschlagen ({e1}), versuche Fallback …")
|
||
download_file(BEAMMP_FALLBACK_URL, dest + ".zip", self.log, prog)
|
||
self.log(f"Fallback-ZIP gespeichert. Bitte BeamMP-Launcher.exe daraus entpacken. ({dest}.zip)")
|
||
return
|
||
size = os.path.getsize(dest)
|
||
if size < 1_000_000:
|
||
# evtl. HTML-Fehlerseite
|
||
with open(dest, "rb") as f:
|
||
head = f.read(200)
|
||
if b"<html" in head.lower():
|
||
raise ValueError("Server lieferte HTML statt .exe (Release evtl. umbenannt – siehe Log)")
|
||
self.log(f"Fertig: {size} Bytes → {dest}")
|
||
GLib.idle_add(self.progress.set_fraction, 1.0)
|
||
GLib.idle_add(self.progress.set_text, "Download fertig")
|
||
except Exception as e:
|
||
self.log(f"FEHLER beim Download: {e}")
|
||
self.log("Manuell laden: https://github.com/BeamMP/BeamMP-Launcher/releases/latest")
|
||
GLib.idle_add(self.progress.set_text, "Download fehlgeschlagen")
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
def on_install(self, _):
|
||
self._save_config()
|
||
if not self.beamng:
|
||
self.log("FEHLER: BeamNG.drive nicht gefunden – erst in Steam installieren.")
|
||
return
|
||
launcher = self.ent_launcher.get_text().strip()
|
||
if not launcher or not os.path.isfile(launcher):
|
||
self.log(f"FEHLER: Launcher nicht gefunden: {launcher} – erst herunterladen.")
|
||
return
|
||
sel = self._selected_proton()
|
||
if not sel:
|
||
self.log("FEHLER: keine Proton-Version gewählt.")
|
||
return
|
||
proton_name, proton_bin, _tool = sel
|
||
game_dir = self.beamng["game_dir"]
|
||
compat = self.beamng["compatdata"]
|
||
prefix = self.beamng["prefix"]
|
||
steam = self.beamng["steam"]
|
||
self.log(f"Installiere mit {proton_name} in Prefix {prefix} …")
|
||
threading.Thread(target=self._install_work, daemon=True,
|
||
args=(launcher, game_dir, compat, prefix, steam, proton_bin, proton_name)).start()
|
||
|
||
def _install_work(self, launcher, game_dir, compat, prefix, steam, proton_bin, proton_name):
|
||
try:
|
||
if not os.path.isdir(os.path.join(prefix, "drive_c")):
|
||
self.log("Prefix existiert noch nicht – bitte BeamNG.drive einmal über Steam starten, dann erneut versuchen.")
|
||
self.log(f"Erwartet: {prefix}/drive_c")
|
||
return
|
||
target = official_launcher_dest(prefix)
|
||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||
shutil.copy2(launcher, target)
|
||
self.log(f"Launcher kopiert nach: {target}")
|
||
# Zweite Kopie für leichten Zugriff
|
||
alt = os.path.join(prefix, "drive_c", "BeamMP", "BeamMP-Launcher.exe")
|
||
os.makedirs(os.path.dirname(alt), exist_ok=True)
|
||
if os.path.abspath(alt) != os.path.abspath(target):
|
||
shutil.copy2(launcher, alt)
|
||
self.log(f"Kopie nach: {alt}")
|
||
|
||
# Einmalig via Proton starten, damit BeamMP Mod/Config angelegt werden.
|
||
env = os.environ.copy()
|
||
env["STEAM_COMPAT_DATA_PATH"] = compat
|
||
env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = steam
|
||
env["STEAM_COMPAT_INSTALL_PATH"] = game_dir
|
||
self.log("Starte Launcher einmalig via Proton (Fenster bestätigen, dann schließen) …")
|
||
self.log(f" STEAM_COMPAT_DATA_PATH={compat}")
|
||
proc = subprocess.Popen([proton_bin, "run", target], env=env, cwd=game_dir,
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||
self.log(f"Proton läuft (PID {proc.pid}) – bitte Installation im Fenster abschließen.")
|
||
try:
|
||
out, _ = proc.communicate(timeout=300)
|
||
self.log(f"Proton beendet (Code {proc.returncode}).")
|
||
if out:
|
||
self.log("Proton-Log (Auszug): " + out.strip().replace(chr(10), " | ")[:800])
|
||
except subprocess.TimeoutExpired:
|
||
self.log("Launcher läuft noch (Timeout 5 min) – Fenster manuell schließen, dann fortfahren.")
|
||
self.log("Schritt 1 fertig → jetzt Schritt 2 (Zu Steam hinzufügen).")
|
||
except Exception as e:
|
||
self.log(f"FEHLER bei Installation: {e}")
|
||
|
||
def on_add_steam(self, _):
|
||
self._save_config()
|
||
if not self.beamng:
|
||
self.log("FEHLER: BeamNG nicht gefunden.")
|
||
return
|
||
sel = self._selected_proton()
|
||
if not sel:
|
||
self.log("FEHLER: keine Proton-Version gewählt.")
|
||
return
|
||
proton_name, _pb, _td = sel
|
||
compat_name = proton_to_compat_name(proton_name)
|
||
name = self.ent_name.get_text().strip() or "BeamMP"
|
||
prefix = self.beamng["prefix"]
|
||
game_dir = self.beamng["game_dir"]
|
||
steam = self.beamng["steam"]
|
||
target = official_launcher_dest(prefix)
|
||
if not os.path.isfile(target):
|
||
# Fallback auf eingetragenen Pfad
|
||
launcher = self.ent_launcher.get_text().strip()
|
||
if launcher and os.path.isfile(launcher):
|
||
self.log("Launcher noch nicht im Prefix – kopiere jetzt …")
|
||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||
shutil.copy2(launcher, target)
|
||
else:
|
||
self.log(f"FEHLER: {target} fehlt – erst Schritt 1 ausführen.")
|
||
return
|
||
if steam_running():
|
||
self.log("WARNUNG: Steam läuft! Bitte Steam vollständig beenden (Steam → Beenden), sonst wird shortcuts.vdf überschrieben.")
|
||
proton_bin = sel[1]
|
||
threading.Thread(target=self._steam_work, daemon=True,
|
||
args=(name, target, game_dir, steam, compat_name, proton_name, proton_bin)).start()
|
||
|
||
def _steam_work(self, name, exe_linux, startdir, steam, compat_name, proton_name, proton_bin):
|
||
try:
|
||
compatdata = self.beamng["compatdata"]
|
||
game_dir = self.beamng["game_dir"]
|
||
# Der Launcher braucht BeamNG.drive.ini im 284160-Prefix (installPath).
|
||
ini = os.path.join(self.beamng["prefix"], "drive_c", "users",
|
||
"steamuser", "AppData", "Local", "BeamNG", "BeamNG.drive.ini")
|
||
if not os.path.isfile(ini):
|
||
self.log("HINWEIS: BeamNG.drive.ini fehlt im Prefix – bitte BeamNG einmal normal über Steam starten, dann erneut versuchen.")
|
||
# Wrapper erzwingt das BeamNG-Prefix (284160). Direkt-Exe bekäme sonst
|
||
# ein eigenes frisches Prefix ohne ini -> Launcher-Fehler Code 7.
|
||
wrapper = os.path.join(os.path.expanduser("~/scripts"), WRAPPER_NAME)
|
||
with open(wrapper, "w") as f:
|
||
f.write("#!/bin/bash\n")
|
||
f.write("# BeamMP im BeamNG-Protonprefix (284160) starten. Wird vom GUI-Skript erzeugt.\n")
|
||
f.write("# Steam-Shortcut zeigt auf DIESE Datei (nativ, KEIN Proton-Compat erzwingen).\n")
|
||
f.write(f'export STEAM_COMPAT_DATA_PATH="{compatdata}"\n')
|
||
f.write(f'export STEAM_COMPAT_CLIENT_INSTALL_PATH="{steam}"\n')
|
||
f.write(f'export STEAM_COMPAT_INSTALL_PATH="{game_dir}"\n')
|
||
f.write(f'cd "{game_dir}"\n')
|
||
f.write(f'exec "{proton_bin}" run "{exe_linux}"\n')
|
||
os.chmod(wrapper, 0o755)
|
||
self.log(f"Wrapper-Skript: {wrapper}")
|
||
|
||
appid = steam_shortcut_appid(wrapper, name)
|
||
unsigned = appid & 0xFFFFFFFF
|
||
# Alte AppID (direkte Exe) zum Aufräumen des stale Compat-Mappings
|
||
old_appid = steam_shortcut_appid(exe_linux, name)
|
||
old_unsigned = old_appid & 0xFFFFFFFF
|
||
self.log(f"Shortcut-AppID: {appid} (unsigned {unsigned})")
|
||
|
||
for uid in sorted(os.listdir(os.path.join(steam, "userdata"))):
|
||
cfg = os.path.join(steam, "userdata", uid, "config")
|
||
if not os.path.isdir(cfg):
|
||
continue
|
||
vdf = os.path.join(cfg, "shortcuts.vdf")
|
||
if os.path.isfile(vdf):
|
||
shutil.copy2(vdf, vdf + ".beammp-gui.bak")
|
||
try:
|
||
top, keys = read_shortcuts_vdf(vdf) if os.path.isfile(vdf) else ({}, [])
|
||
except Exception as e:
|
||
self.log(f"{vdf} konnte nicht gelesen werden ({e}) – lege neu an.")
|
||
top, keys = {}, []
|
||
# Duplikat? (gleicher Name, alte direkte Exe oder Wrapper)
|
||
dup_key = None
|
||
for k, entry in top.items():
|
||
if entry.get("AppName") == name or entry.get("Exe") in (exe_linux, wrapper):
|
||
dup_key = k
|
||
break
|
||
entry = {
|
||
"appid": appid,
|
||
"AppName": name,
|
||
"Exe": wrapper,
|
||
"StartDir": startdir,
|
||
"icon": "",
|
||
"ShortcutPath": "",
|
||
"LaunchOptions": "",
|
||
"IsHidden": 0,
|
||
"AllowDesktopConfig": 1,
|
||
"AllowOverlay": 1,
|
||
"OpenVR": 0,
|
||
"Devkit": 0,
|
||
"DevkitGameID": "",
|
||
"DevkitOverrideAppID": 0,
|
||
"LastPlayTime": int(time.time()),
|
||
"FlatpakAppID": "",
|
||
"tags": {},
|
||
}
|
||
if dup_key is not None:
|
||
# Felder aktualisieren, LastPlayTime behalten falls neuer=0
|
||
old = top[dup_key]
|
||
old.update(entry)
|
||
old["appid"] = appid
|
||
self.log(f"Eintrag '{name}' aktualisiert (User {uid}).")
|
||
else:
|
||
new_index = str(max([int(k) for k in top.keys()] + [-1]) + 1)
|
||
top[new_index] = entry
|
||
keys = sorted(top.keys(), key=int)
|
||
self.log(f"Eintrag '{name}' neu erstellt (User {uid}, Index {new_index}).")
|
||
write_shortcuts_vdf(vdf, top, keys)
|
||
self.log(f"shortcuts.vdf geschrieben: {vdf}")
|
||
|
||
# Wrapper läuft NATIV (ruft selbst proton auf) -> KEIN Proton-Compat
|
||
# für den Shortcut erzwingen, sonst läuft der Wrapper doppelt unter Proton.
|
||
# Altes Mapping der direkten Exe ebenfalls entfernen.
|
||
config_vdf = os.path.join(steam, "config", "config.vdf")
|
||
if os.path.isfile(config_vdf):
|
||
try:
|
||
for uid_num in {unsigned, old_unsigned}:
|
||
if remove_compat_mapping(config_vdf, uid_num):
|
||
self.log(f"CompatToolMapping für {uid_num} entfernt (nativer Wrapper).")
|
||
self.log("Wichtig: In Steam bei BeamMP unter Eigenschaften → Kompatibilität "
|
||
"KEIN Häkchen setzen (nativ über Wrapper).")
|
||
except Exception as e:
|
||
self.log(f"Hinweis: CompatMapping-Aufräumen fehlgeschlagen ({e}).")
|
||
else:
|
||
self.log(f"Hinweis: {config_vdf} nicht gefunden.")
|
||
self.log("Fertig! Steam neu starten – BeamMP erscheint in der Bibliothek.")
|
||
self.log("Tipp: Falls Launcher Code 7 meldet, BeamNG einmal normal starten (erzeugt BeamNG.drive.ini).")
|
||
except Exception as e:
|
||
import traceback
|
||
self.log(f"FEHLER beim Steam-Eintrag: {e}")
|
||
self.log(traceback.format_exc(limit=3).replace(chr(10), " | "))
|
||
|
||
def on_all(self, _):
|
||
self.log("Führe Schritt 1 aus, Schritt 2 bitte nach Abschluss von 1 klicken.")
|
||
self.on_install(_)
|
||
|
||
|
||
class App(Gtk.Application):
|
||
def __init__(self):
|
||
super().__init__(application_id="com.beammp.gui")
|
||
self.win = None
|
||
|
||
def do_activate(self):
|
||
if not self.win:
|
||
self.win = MainWindow(self)
|
||
self.win.show_all()
|
||
self.win.present()
|
||
|
||
|
||
def main():
|
||
if "--detect" in sys.argv:
|
||
# Headless-Diagnose für Tests
|
||
print("steam:", find_steam_root())
|
||
print("libs:", parse_libraryfolders(find_steam_root() or ""))
|
||
print("beamng:", find_beamng())
|
||
print("proton:", [(n, pb) for n, pb, _ in find_proton_versions()])
|
||
return
|
||
app = App()
|
||
app.run()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|