class_name EditorUpdater extends RefCounted ## Editor-Selbstupdate (Karte t_d95f087a) — Update-Check über Git, Update- ## Ausführung (git pull + Godot-Import-Lauf) und plattformneutrale Restart- ## Auflösung. Alles static und ohne UI (tests/test_updater.gd). ## ## Regeln (Karte + Sly-Ansatz „das Programm sich selbst updaten lassen“): ## - Transport = Git (Klon des Editors), KEIN Binary-Download. ## - Keine Linux-Pfade hartkodiert: git (default "git") und ein optionaler ## Restart-Befehl kommen aus user://scopedvr_editor.cfg, Abschnitt [update] ## (git_command / restart_command). Ohne Konfiguration: unter Linux wird ## erkannt, ob eine systemd-User-Unit „map-editor“ existiert — dann klingelt ## ein Transient-Timer-Kick (systemd-run) den Unit-Neustart (der Kick läuft ## im EIGENEN Scope und überlebt die Stop-Phase). Sonst: der Editor startet ## sich selbst neu (mode "self", self_restart_args). ## - Import-Lauf-Lektion 17.10.: ein --import-Lauf kann mit Exit-Code 0 ## ENDEN und trotzdem Parse-/Script-Fehler enthalten (class_name-Cache). ## Die Ausgabe wird deshalb zusätzlich gescannt — bei Fehlern wird NICHT ## neu gestartet (Abbruch mit Meldung), sonst würde Sly einen Editor ohne ## Map-Ansicht sehen. ## - Offline / Tunnel weg → status "unreachable" → die UI bleibt still ## (kein Banner, kein Fehler-Geballer). ## - Git darf NIE interaktiv werden (Karte t_1457e935): kein GUI-Credential- ## Dialog mitten im Editor! Jeder git-Lauf läuft deshalb mit ## credential.helper=store und abgeschaltetem Askpass (spawn_argv) — ## fehlen die Zugangsdaten, endet der Lauf sofort mit einer klaren ## Fehlermeldung (status "auth_failed"), nie mit einem Prompt oder Hang. ## Hintergrund 17.10.: der systemd-User-Manager exportiert SSH_ASKPASS ## (ksshaskpass) — ein nackter git-Spawn hat damit mitten im Editor das ## Login-Fenster geöffnet. GIT_ASKPASS-/core.askPass-Override schlägt das ## geerbte SSH_ASKPASS (verifiziert am PC, 17.10.). const BRANCH := "main" const UNIT_NAME := "map-editor" ## Statuszeilen-Text für den auth_failed-Pfad (UI + Tests). const AUTH_ERROR_TEXT := "Updates nicht prüfbar — Zugangsdaten fehlen (kein Login-Dialog geöffnet)." ## Belt-and-braces (Karte t_1457e935): argv für einen NICHT-interaktiven ## git-Lauf bauen. Rückgabe: [0] = Programm, Rest = Argumente. ## - "-c credential.helper=" leert ERST alle geerbten Helfer (sonst öffnet ## z. B. der Windows-Credential-Manager sein GUI-Fenster), danach ## "-c credential.helper=store": ausschließlich der Credential-Store ## (~/.git-credentials per HOME). ## - POSIX: Spawn über /usr/bin/env mit GIT_TERMINAL_PROMPT=0 und ## GIT_ASKPASS=/bin/true — das überstimmt auch ein GEERBTES SSH_ASKPASS ## (KDE/GNOME-Login-Dialog!). HOME kommt aus der eigenen Umgebung ## (OS.get_environment, kein Hardcode) und wird nur mitgegeben, wenn da. ## - Fallback ohne env-Wrapper (env fehlt / kein HOME): trotzdem kein Prompt ## möglich, weil core.askPass=/bin/true das Askpass-Fallback abschaltet. static func spawn_argv(git_exe: String, project_dir: String, args: PackedStringArray) -> PackedStringArray: var git_args := PackedStringArray(["-C", project_dir, "-c", "credential.helper=", "-c", "credential.helper=store"]) if not OS.has_feature("windows"): git_args.append_array(PackedStringArray(["-c", "core.askPass=/bin/true"])) git_args.append_array(args) var home := OS.get_environment("HOME") if not OS.has_feature("windows") and FileAccess.file_exists("/usr/bin/env") \ and not home.is_empty(): var out := PackedStringArray(["/usr/bin/env", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/true", "HOME=" + home]) out.append(git_exe) out.append_array(git_args) return out var out := PackedStringArray([git_exe]) out.append_array(git_args) return out ## Ein Git-Kommando im Projektordner ausführen — IMMER nicht-interaktiv ## (spawn_argv). Rückgabe {ok, code, output} (output = stdout+stderr gemischt, ## damit Fehlerklassifikation sehen kann, was git gesagt hat). static func git_run(git_exe: String, project_dir: String, args: PackedStringArray) -> Dictionary: var argv := spawn_argv(git_exe, project_dir, args) var exe := str(argv[0]) var rest := argv.slice(1) var output: Array = [] var code := OS.execute(exe, rest, output, true) var text := "" for line in output: text += str(line) return {"ok": code == 0, "code": code, "output": text} ## Pure Prüfung (testbar): deutet die git-Ausgabe auf FEHLENDE Zugangsdaten? ## (Askpass ist abgeschaltet — „could not read Username“ ist dann der ## dokumentierte Nicht-Interaktiv-Fehler, kein Anlass für einen Retry.) static func looks_like_auth_error(output: String) -> bool: return output.contains("could not read Username") \ or output.contains("Authentication failed") \ or output.contains("terminal prompts disabled") \ or output.contains("Unauthorized") ## Git-Binary: Konfiguration (user://scopedvr_editor.cfg → [update] ## git_command) gewinnt, sonst das Standard-"git" (PATH). static func git_exe(cfg: ConfigFile = null) -> String: if cfg != null: var custom := str(cfg.get_value("update", "git_command", "")) if not custom.is_empty(): return custom return "git" ## Lokaler HEAD-SHA. "" = kein Git-Repository (oder git nicht gefunden). static func head_sha(git_exe: String, project_dir: String) -> String: var r := git_run(git_exe, project_dir, PackedStringArray(["rev-parse", "HEAD"])) if not bool(r["ok"]): return "" var text := str(r["output"]).strip_edges() if text.is_empty(): return "" return text.split("\n")[0].strip_edges() ## Remote-SHA per git ls-remote (cheap, lesend — keine lokalen Referenzen ## verändert). "" = nicht erreichbar / Branch fehlt / Credentials fehlen. static func remote_sha(git_exe: String, project_dir: String, branch: String) -> String: var r := git_run(git_exe, project_dir, PackedStringArray([ "ls-remote", "origin", "refs/heads/%s" % branch])) if not bool(r["ok"]): return "" var text := str(r["output"]).strip_edges() if text.is_empty(): return "" return sha_aus_ls_remote(text, branch) ## Update-Check: lokaler HEAD vs. origin/main. ## Rückgabe {status, local, remote, error} — status: ## "update_available" | "up_to_date" | "unreachable" | "error" | "auth_failed" ## "error" = kein Repo/git nicht da (Editor-spezifisches Problem) ## "unreachable" = Origin nicht erreichbar (offline, Tunnel weg) ## "auth_failed" = Zugangsdaten fehlen → UI zeigt klaren Hinweis, NIEMALS ## einen Login-Prompt (Karte t_1457e935). static func check_update(git_exe: String, project_dir: String) -> Dictionary: var local := head_sha(git_exe, project_dir) if local.is_empty(): return {"status": "error", "local": "", "remote": "", "error": "Kein Git-Repository (oder git nicht gefunden)."} var r := git_run(git_exe, project_dir, PackedStringArray([ "ls-remote", "origin", "refs/heads/%s" % BRANCH])) if not bool(r["ok"]): var text := str(r["output"]) if looks_like_auth_error(text): return {"status": "auth_failed", "local": local, "remote": "", "error": AUTH_ERROR_TEXT} return {"status": "unreachable", "local": local, "remote": "", "error": "Origin nicht erreichbar (offline oder Tunnel weg)."} var text2 := str(r["output"]).strip_edges() if text2.is_empty(): return {"status": "unreachable", "local": local, "remote": "", "error": "Origin nicht erreichbar (offline oder Tunnel weg)."} var remote := sha_aus_ls_remote(text2, BRANCH) if remote.is_empty(): # Kein echter Commit in der Ausgabe (nur Warnungen/leer) — NICHT als # Update deuten, sonst hängt das Banner endlos (Karte t_50a07d4b-Live-Bug). return {"status": "unreachable", "local": local, "remote": "", "error": "Origin lieferte keinen Commit (nur Warnungen)."} if local == remote: return {"status": "up_to_date", "local": local, "remote": remote, "error": ""} # Befund 25.10.: „Update verfügbar“ blieb nach dem Update stehen, weil nur # HEAD != origin/main geprüft wurde. Steht der Klon auf einem anderen # Branch oder VOR main (z. B. Feature-Branch), ist das KEIN Update: # nur melden, wenn der Remote-Commit NICHT schon in HEAD steckt. if remote_in_head(git_exe, project_dir, remote): return {"status": "up_to_date", "local": local, "remote": remote, "error": ""} return {"status": "update_available", "local": local, "remote": remote, "error": ""} ## Ist `remote` schon Vorfahre von HEAD (Klon gleich oder VORAUS)? Unbekannter ## Commit (Exit 128) = noch nicht geholt = echtes Update → false. static func remote_in_head(git_exe: String, project_dir: String, remote: String) -> bool: var r := git_run(git_exe, project_dir, PackedStringArray([ "merge-base", "--is-ancestor", remote, "HEAD"])) return bool(r["ok"]) ## Aus der gemischten ls-remote-Ausgabe den Commit für `branch` lesen. ## WARUM: `git_run` mischt stdout+stderr, und der git-Client schreibt bei einer ## HTTP-Umleitung zuerst "warning: Umleitung nach ..." — die alte Fassung nahm ## stumpf die ERSTE Zeile und hielt "warning" für den Ziel-Commit. Ergebnis war ## ein Banner "Update verfügbar: → warning", das nie verschwand, obwohl ## der Pull korrekt lief (live belegt 20.10., Klon stand auf origin/main). ## Regeln: nur Zeilen "\t" zählen, sha = echter Hex-String. static func sha_aus_ls_remote(text: String, branch: String) -> String: var ziel := "refs/heads/%s" % branch var locker := "" for zeile in text.split("\n"): var z := zeile.strip_edges() if z.is_empty(): continue var klein := z.to_lower() if klein.begins_with("warning") or klein.begins_with("hint") \ or klein.begins_with("fatal") or klein.begins_with("error"): continue var teile := z.split("\t") if teile.size() < 2: continue var sha := teile[0].strip_edges() if not ist_sha(sha): continue if teile[1].strip_edges() == ziel: return sha if locker.is_empty(): locker = sha return locker ## Sieht der Text wie ein Git-Objekt-Hash aus (7-40 Hex-Zeichen)? static func ist_sha(text: String) -> bool: var t := text.strip_edges() if t.length() < 7 or t.length() > 40: return false for i in t.length(): var c := t[i] if c >= "0" and c <= "9": continue if c >= "a" and c <= "f": continue if c >= "A" and c <= "F": continue return false return true ## Kurzer SHA für Anzeigen (7 Zeichen, wie git log --oneline). static func short_sha(sha: String) -> String: return sha.substr(0, 7) ## Banner-Text (pure, testbar). static func banner_text(local_sha: String, remote_sha: String) -> String: return "🔄 Update verfügbar: %s → %s" % [short_sha(local_sha), short_sha(remote_sha)] ## Klartext-Status für die Statuszeile (Karte t_f73a1cc5): Wenn lokale ## (meist von Godot erzeugte) Dateien das Update blockieren WÜRDEN, legt der ## Pull sie selbst beiseite — Sly liest diesen Satz statt einer rohen Git- ## Zeile („Bitte verschieben oder entfernen Sie diese…“). const AUTO_STASH_INFO_TEXT := "Update blockiert: eigene erzeugte Dateien — werden automatisch beiseitegelegt und das Update läuft weiter." ## Ehrliche Meldung, wenn das Zurückholen kollidiert ist (Stash bleibt als ## Sicherung liegen; Godot baut die erzeugten Dateien beim Import neu). const STASH_KEPT_TEXT := "Erzeugte Dateien bleiben beiseitegelegt — beim nächsten Start neu aufgebaut (Sicherungs-Stash bleibt liegen, nichts verloren)." ## Kennung der eigenen Auto-Stashes (Verwerfen alter Einträge, damit sie ## sich nicht anhäufen). const AUTO_STASH_MESSAGE := "auto: Selbst-Update" ## Update ausführen: git pull --ff-only (Klon zieht nur nach, dreht nichts ## um). Vorher werden lokal Erzeugtes/Geändertes AUTOMATISCH beiseitegelegt ## (Karte t_f73a1cc5): `git stash push -u` erfasst GEÄNDERT **und** UNTRACKED ## Dateien (`--autostash` allein reicht nicht — es lässt untracked liegen; ## genau die haben am 19.10. den Pull blockiert). Nach dem Pull wird das ## Beiseitegelegte zurückgeholt; kollidiert das (Typfall: lose `.import`- ## Datei trifft auf dieselbe Datei neu getrackt), bleibt der Stash als ## Sicherung liegen und die Statuszeile bekommt den Klartext — NIE ein halb ## gepoppter Zustand. Rückgabe {ok, output, errors, info} (info = Klartext ## fürs Statuszeile-Fenster, leer wenn nichts besonderes passiert ist). static func pull(git_exe: String, project_dir: String) -> Dictionary: var stash_info := _stash_local_changes(git_exe, project_dir) # Gezielt origin/main ziehen (nicht den Upstream des aktuellen Branches). var r := git_run(git_exe, project_dir, PackedStringArray(["pull", "--ff-only", "origin", BRANCH])) var errors: Array = [] var text := str(r["output"]).strip_edges() var info := "" if not bool(r["ok"]): # Pull fehlgeschlagen (z. B. offline): Beiseitegelegtes zurückholen, # damit nichts „verschwindet", wenn der Fehler nur am Netz lag. if not stash_info.is_empty(): _restore_stash(git_exe, project_dir) errors.append("git pull --ff-only fehlgeschlagen (Exit-Code %s):" % str(r["code"])) if not text.is_empty(): errors.append(text) else: errors.append("(keine Ausgabe — git nicht gefunden oder Verbindung sofort weg)") if looks_like_auth_error(text): errors.append("Zugangsdaten fehlen — es wird bewusst KEIN Login-Dialog geöffnet (Credential-Store prüfen).") else: if not stash_info.is_empty(): info = AUTO_STASH_INFO_TEXT var restored := _restore_stash(git_exe, project_dir) if not bool(restored["ok"]): info += "\n" + STASH_KEPT_TEXT # Der Map-Kern ist ein Submodul: ein Pull allein holt ihn nicht nach. var sub := git_run(git_exe, project_dir, PackedStringArray(["submodule", "update", "--init", "--recursive"])) if not bool(sub["ok"]): errors.append("git submodule update fehlgeschlagen (Exit-Code %s):" % str(sub["code"])) errors.append(str(sub["output"]).strip_edges()) if looks_like_auth_error(str(sub["output"])): errors.append("Zugangsdaten fehlen — es wird bewusst KEIN Login-Dialog geöffnet (Credential-Store prüfen).") return {"ok": false, "output": str(sub["output"]), "errors": errors, "info": info} return {"ok": bool(r["ok"]), "output": str(r["output"]), "errors": errors, "info": info} ## Lokales Beiseitelegen vor dem Pull (Karte t_f73a1cc5). Rückgabe: Referenz ## auf den eigenen Auto-Stash (z. B. "stash@{0}") oder "" wenn es nichts zu ## beiseitelegen gab / der Stash nicht angelegt werden konnte. ## - Zuerst alte EIGENE Auto-Stashes verwerfen (gleiche Kennung) — sie ## enthalten nur erzeugte Dateien und dürfen sich nicht anhäufen. ## - "No local changes to save" endet ebenfalls mit Exit 0 → NICHT am Text ## matchen, sondern prüfen, ob ein Eintrag mit unserer Kennung entstand. static func _stash_local_changes(git_exe: String, project_dir: String) -> String: _drop_auto_stashes(git_exe, project_dir) var r := git_run(git_exe, project_dir, PackedStringArray([ "stash", "push", "-u", "-m", AUTO_STASH_MESSAGE])) if not bool(r["ok"]): return "" return _auto_stash_ref(git_exe, project_dir) ## Referenz des eigenen Auto-Stashes ("stash@{N}") oder "". static func _auto_stash_ref(git_exe: String, project_dir: String) -> String: var r := git_run(git_exe, project_dir, PackedStringArray([ "stash", "list", "--pretty=format:%gd|%s"])) if not bool(r["ok"]): return "" for line in str(r["output"]).split("\n"): var parts := str(line).strip_edges().split("|", true, 1) if parts.size() == 2 and str(parts[1]).contains(AUTO_STASH_MESSAGE): return str(parts[0]) return "" ## Alle EIGENEN Auto-Stashes (Kennung AUTO_STASH_MESSAGE) verwerfen — von ## hinten, weil die Indizes sich beim Drop verschieben. static func _drop_auto_stashes(git_exe: String, project_dir: String) -> void: var r := git_run(git_exe, project_dir, PackedStringArray([ "stash", "list", "--pretty=format:%gd|%s"])) if not bool(r["ok"]): return var refs: Array = [] for line in str(r["output"]).split("\n"): var parts := str(line).strip_edges().split("|", true, 1) if parts.size() == 2 and str(parts[1]).contains(AUTO_STASH_MESSAGE): refs.append(str(parts[0])) refs.reverse() for ref in refs: git_run(git_exe, project_dir, PackedStringArray(["stash", "drop", ref])) ## Beiseitegelegtes zurückholen. apply (nicht pop), damit die Referenz nicht ## am Stack verschiebt; bei Erfolg wird der Eintrag gedroppt. Kollidiert das ## Zurückholen (Typfall: erzeugte Datei ist inzwischen getrackt), wird mit ## `git reset --hard` der halb gepoppte Zustand weggeräumt und der Stash ALS ## SICHERUNG LIEGEN GELASSEN — kein Datenverlust, Godot erzeugt die Dateien ## beim nächsten Import-Lauf neu. Bewusst KEIN `git clean`: untracked ## Nutzdaten (z. B. Maps/) dürfen nie angefasst werden. ## Rückgabe {ok, kept, output}. static func _restore_stash(git_exe: String, project_dir: String) -> Dictionary: var ref := _auto_stash_ref(git_exe, project_dir) if ref.is_empty(): return {"ok": true, "kept": false, "output": ""} var r := git_run(git_exe, project_dir, PackedStringArray(["stash", "apply", ref])) if bool(r["ok"]): git_run(git_exe, project_dir, PackedStringArray(["stash", "drop", ref])) return {"ok": true, "kept": false, "output": str(r["output"])} git_run(git_exe, project_dir, PackedStringArray(["reset", "--hard"])) return {"ok": false, "kept": true, "output": str(r["output"])} ## Import-Lauf: --headless --path --import. Muss NACH dem ## Pull laufen (neue class_name-Dateien in den Cache), FEHLSCHLAG = Update- ## Abbruch OHNE Neustart (Lektion 17.10.). Rückgabe {ok, errors, output}. static func run_import(godot_exe: String, project_dir: String) -> Dictionary: var args := PackedStringArray(["--headless", "--path", project_dir, "--import"]) var output: Array = [] var code := OS.execute(godot_exe, args, output, true) var errors := import_errors(output) if code != 0: errors.append("Import-Lauf fehlgeschlagen (Exit-Code %s)" % str(code)) return {"ok": errors.is_empty(), "errors": errors, "output": _output_text(output)} ## Pure Prüfung (testbar): enthält die Import-Ausgabe Parse-/Script-Fehler? ## Exit-Code 0 allein beweist NICHT einen sauberen Import (Lektion 17.10.). static func import_errors(output: Array) -> Array: var errors: Array = [] for line in output: var text := str(line) if text.contains("Parse Error") or text.contains("SCRIPT ERROR"): errors.append(text.strip_edges()) return errors static func _output_text(output: Array) -> String: var text := "" for line in output: text += str(line) return text ## Restart-Auflösung (plattformneutral, getestet): ## 1. cfg [update] restart_command gesetzt → mode "command", argv = an ## Leerzeichen gesplittet. (z. B. systemd-Unit-Kick, Batch-Skript — jede ## Plattform setzt hier ihren eigenen Befehl ein.) ## 2. Linux + systemd-User-Unit „map-editor“ existiert → Transient-Timer- ## Kick (systemd-run, 3 s): der Kick läuft in einem EIGENEN Scope und ## wird beim Stoppen des Editors (Unit-Neustart) nicht mitgekillt. ## 3. Sonst: mode "self" — der Editor startet sich selbst neu (gleiche ## Godot-Binary, --editor --path ) und beendet sich danach. static func resolve_restart(cfg: ConfigFile = null) -> Dictionary: if cfg != null: var cmd := str(cfg.get_value("update", "restart_command", "")) if not cmd.is_empty(): return {"mode": "command", "argv": PackedStringArray(cmd.split(" ", false))} if OS.has_feature("linux"): var probe: Array = [] var code := OS.execute("systemctl", PackedStringArray([ "--user", "list-unit-files", UNIT_NAME + ".service", "--no-legend"]), probe, true) var text := "" for line in probe: text += str(line) if code == 0 and not text.strip_edges().is_empty(): return {"mode": "command", "argv": PackedStringArray([ "systemd-run", "--user", "--on-active=3s", "systemctl", "--user", "restart", UNIT_NAME + ".service"])} return {"mode": "self", "argv": PackedStringArray()} ## Argumente für den Selbst-Restart: dieselbe Godot-Binary startet die ## Map-Editor-APP über deren main.tscn (kein "--editor" — das würde den ## Godot-Editor-Shell öffnen, nicht unsere App; Live-Befund 17.10.: ## Nach dem Selbst-Update ging der Godot-Editor auf statt dem Map Editor). static func self_restart_args(project_dir: String) -> PackedStringArray: return PackedStringArray(["--path", project_dir])