Map-Editor Selbst-Update (t_d95f087a): Start-Check + Banner + Jetzt-updaten + Pruef-Button — EditorUpdater (src/editor/updater.gd): stiller Check beim Start (git ls-remote origin/main vs. lokaler HEAD, keep-alive-leicht), Bernstei-Banner „🔄 Update verfügbar: alt → neu“ mit Knopf „Jetzt updaten“, Datei-Menü-ID 7 „🔄 Updates prüfen“ mit Statuszeilen-Ergebnis (Update verfügbar / auf dem neusten Stand / Nicht erreichbar). Update-Ausführung: Dirty-Schutz via _request_lossy (derselbe deutsche Dialog wie Strg+O/N, Abbrechen bricht ab), dann git pull --ff-only (Tunnel + credential store) → Godot-Import-Lauf (--headless --import) → Neustart. Import-Ausgabe wird geparst: Parse-/SCRIPT-Fehler trotz Exit-Code 0 = Abbruch mit Meldung, KEIN Neustart (Lektion 17.10. class_name-Cache). Restart plattformneutral: cfg [update] restart_command > systemd-User-Unit map-editor auto-erkennung (systemd-run --on-active=3s Kick im eigenen Scope, überlebt den Unit-Neustart) > Selbst-Restart (dieselbe Godot-Binary, --editor --path). Keine Linux-Pfade hartkodiert; git_command aus cfg möglich. Offline: kein Banner, kein Fehler-Geballer. Ein-Hintergrund-Thread je Richtung (check/update) mit Busy-Guard, Thread-join über call_deferred-Ordnung. Tests: neue Suite test_updater (37 Checks: Check divergiert/gleich/unreachable/kein-Repo, Banner-Texte, Pull gegen echte Temp-Klone, Import-Abbruch-Pfad inkl. Exit-0-Falle, Restart-Auflösung), Suite 13/13 ALL OK (997 Checks). docs/tasten.md: Abschnitt „Updates“; H-Overlay-Zeile.
This commit is contained in:
parent
2065ef2252
commit
f2b4f6a824
8 changed files with 607 additions and 1 deletions
|
|
@ -81,6 +81,35 @@ nichts geht versehentlich verloren.
|
|||
| **Strg+O** | Map öffnen |
|
||||
| **Strg+N** | Neue Map |
|
||||
|
||||
## Updates (Datei-Menü)
|
||||
|
||||
Der Editor aktualisiert sich selbst aus seinem Git-Klon (Karte t_d95f087a) —
|
||||
kein Programmdownload, nur „Versionsstände holen“:
|
||||
|
||||
| Weg | Wirkung |
|
||||
| --- | --- |
|
||||
| **Beim Start** | Still prüfen. Gibt es eine neuere Version, erscheint eine Bernstei-Leiste unter der Menüleiste: **„🔄 Update verfügbar: `<alt>` → `<neu>`“** |
|
||||
| **Banner → „🔄 Jetzt updaten“** | Installiert das Update: neue Version laden (git pull), Import-Lauf, dann startet der Editor von selbst neu. Ungespeicherte Änderungen werden vorher abgefragt (derselbe Dialog wie bei „Neue Map“/„Öffnen“ — **Abbrechen** bricht das Update ab) |
|
||||
| **Datei → „🔄 Updates prüfen“** | Gleicher Check auf Wunsch, mit Ergebnis-Meldung in der Statuszeile: „Update verfügbar“ / „Du bist auf dem neusten Stand (`<sha>`)“ / „Nicht erreichbar“ |
|
||||
|
||||
- **Offline / Tunnel weg:** kein Banner, keine Meldung — der Editor bleibt
|
||||
einfach so, wie er ist. Prüfen kann man trotzdem: die Meldung sagt es dann
|
||||
in der Statuszeile.
|
||||
- **Fehlgeschlagener Import:** bricht das Update mit Klartext-Meldung ab —
|
||||
der Editor läuft UNVERÄNDERT weiter (kein Neustart in einen kaputten Stand,
|
||||
Lektion 17.10. class_name-Cache).
|
||||
- **Neustart:** Der Editor startet sich selbst neu (dasselbe Godot-Programm,
|
||||
derselbe Projektordner, wieder im Editor-Modus). Wer den Editor als
|
||||
systemd-User-Unit betreibt, setzt in `scopedvr_editor.cfg` (Godot-App-Daten)
|
||||
Abschnitt `[update]` einen `restart_command` — z. B.
|
||||
`restart_command="systemd-run --user --on-active=3s systemctl --user restart map-editor"`.
|
||||
Ohne Eintrag wird unter Linux automatisch erkannt, ob die Unit
|
||||
`map-editor.service` existiert; sonst Selbst-Restart.
|
||||
- **Konfiguration** (alle optional, `user://scopedvr_editor.cfg`, Abschnitt
|
||||
`[update]`): `restart_command` (eigener Neustart-Befehl), `git_command`
|
||||
(eigener git-Pfad). Der Import-Lauf nutzt dieselbe Godot-Binary wie der
|
||||
Editor selbst (bzw. `[test] godot_path`).
|
||||
|
||||
## Kartengröße ändern (Datei-Menü)
|
||||
|
||||
„Kartengröße ändern…“ (Datei-Menü) öffnet einen Dialog mit Live-Vorschau:
|
||||
|
|
|
|||
182
src/editor/updater.gd
Normal file
182
src/editor/updater.gd
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
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).
|
||||
|
||||
const BRANCH := "main"
|
||||
const UNIT_NAME := "map-editor"
|
||||
|
||||
|
||||
## Ein Git-Kommando im Projektordner ausführen. Rückgabe {ok, code, output}.
|
||||
static func git_run(git_exe: String, project_dir: String, args: PackedStringArray) -> Dictionary:
|
||||
var full := PackedStringArray(["-C", project_dir])
|
||||
full.append_array(args)
|
||||
var output: Array = []
|
||||
var code := OS.execute(git_exe, full, output, true)
|
||||
var text := ""
|
||||
for line in output:
|
||||
text += str(line)
|
||||
return {"ok": code == 0, "code": code, "output": text}
|
||||
|
||||
|
||||
## 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.
|
||||
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 text.split("\n")[0].strip_edges().split("\t")[0]
|
||||
|
||||
|
||||
## Update-Check: lokaler HEAD vs. origin/main.
|
||||
## Rückgabe {status, local, remote, error} — status:
|
||||
## "update_available" | "up_to_date" | "unreachable" | "error"
|
||||
## "error" = kein Repo/git nicht da (Editor-spezifisches Problem)
|
||||
## "unreachable"= Origin nicht erreichbar (offline, Tunnel weg)
|
||||
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 remote := remote_sha(git_exe, project_dir, BRANCH)
|
||||
if remote.is_empty():
|
||||
return {"status": "unreachable", "local": local, "remote": "",
|
||||
"error": "Origin nicht erreichbar (offline oder Tunnel weg)."}
|
||||
if local == remote:
|
||||
return {"status": "up_to_date", "local": local, "remote": remote, "error": ""}
|
||||
return {"status": "update_available", "local": local, "remote": remote, "error": ""}
|
||||
|
||||
|
||||
## 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)]
|
||||
|
||||
|
||||
## Update ausführen: git pull --ff-only (Klon zieht nur nach, dreht nichts
|
||||
## um). Rückgabe {ok, output, errors}.
|
||||
static func pull(git_exe: String, project_dir: String) -> Dictionary:
|
||||
var r := git_run(git_exe, project_dir, PackedStringArray(["pull", "--ff-only"]))
|
||||
var errors: Array = []
|
||||
if not bool(r["ok"]):
|
||||
errors.append("git pull --ff-only fehlgeschlagen (Exit-Code %s):" % str(r["code"]))
|
||||
var text := str(r["output"]).strip_edges()
|
||||
if not text.is_empty():
|
||||
errors.append(text)
|
||||
else:
|
||||
errors.append("(keine Ausgabe — git nicht gefunden oder Verbindung sofort weg)")
|
||||
return {"ok": bool(r["ok"]), "output": str(r["output"]), "errors": errors}
|
||||
|
||||
|
||||
## Import-Lauf: <godot> --headless --path <projekt> --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 <projekt>) 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 öffnet denselben
|
||||
## Projektordner im Editor-Modus („--editor“ ist nötig — ein nacktes
|
||||
## „--path“ würde das Projekt als SPIEL starten, nicht als Editor).
|
||||
static func self_restart_args(project_dir: String) -> PackedStringArray:
|
||||
return PackedStringArray(["--editor", "--path", project_dir])
|
||||
1
src/editor/updater.gd.uid
Normal file
1
src/editor/updater.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://nxdbipruop30
|
||||
|
|
@ -57,6 +57,7 @@ static func text() -> String:
|
|||
["Strg+Umschalt+S", "Map speichern unter …"],
|
||||
["Strg+O", "Map öffnen"],
|
||||
["Strg+N", "Neue Map"],
|
||||
["Datei → 🔄 Updates prüfen", "Selbst-Update: prüft auf neue Version; Banner → „Jetzt updaten“ (lädt, importiert, startet neu)"],
|
||||
]))
|
||||
out += _header("Werkzeuge & Tests")
|
||||
out += _table(_rows([
|
||||
|
|
|
|||
193
src/ui/main.gd
193
src/ui/main.gd
|
|
@ -132,6 +132,16 @@ var help_overlay_title: Label
|
|||
var help_overlay_text: RichTextLabel
|
||||
var tool_label: Label # permanenter Werkzeugmodus + Flug-Hint
|
||||
|
||||
# Editor-Selbstupdate (Karte t_d95f087a): Start-Check + Banner + „Jetzt updaten“
|
||||
var update_banner: PanelContainer # Leiste „🔄 Update verfügbar …“ (versteckt)
|
||||
var update_label: Label
|
||||
var update_btn: Button # „Jetzt updaten“ im Banner
|
||||
var check_thread: Thread # Update-Check im Hintergrund (Start/Menü)
|
||||
var update_thread: Thread # Update-Ausführung (pull + Import-Lauf)
|
||||
var _update_check_busy := false
|
||||
var _update_running := false
|
||||
var _update_result: Dictionary = {} # letzter Check (Status/Banner-Nachfrage)
|
||||
|
||||
# Dialoge
|
||||
var new_dialog: AcceptDialog
|
||||
var open_dialog: FileDialog
|
||||
|
|
@ -153,6 +163,10 @@ func _ready() -> void:
|
|||
_update_tool_label()
|
||||
_start_with_new_map()
|
||||
_maybe_start_demo_mode()
|
||||
# Selbst-Update (Karte t_d95f087a): STILLER Start-Check im Hintergrund —
|
||||
# nur bei „Update verfügbar“ erscheint das Banner; offline/Tunnel weg
|
||||
# bleibt alles ruhig (kein Banner, keine Meldung).
|
||||
_startup_update_check()
|
||||
|
||||
|
||||
func _load_manifest() -> Dictionary:
|
||||
|
|
@ -181,6 +195,7 @@ func _build_ui() -> void:
|
|||
top_h.add_theme_constant_override("separation", 10)
|
||||
top.add_child(top_h)
|
||||
vbox.add_child(top)
|
||||
_update_banner_build(vbox)
|
||||
|
||||
var file_menu := MenuButton.new()
|
||||
file_menu.text = "Datei"
|
||||
|
|
@ -196,6 +211,7 @@ func _build_ui() -> void:
|
|||
popup.add_separator()
|
||||
popup.add_item("▶ Karte testen", 5)
|
||||
popup.add_separator()
|
||||
popup.add_item("🔄 Updates prüfen", 7)
|
||||
popup.add_item("Beenden", 4)
|
||||
popup.id_pressed.connect(_on_menu_action)
|
||||
top_h.add_child(file_menu)
|
||||
|
|
@ -993,6 +1009,8 @@ func _on_menu_action(id: int) -> void:
|
|||
_on_test_run()
|
||||
6: # Kartengröße ändern (R3, t_c863b93b)
|
||||
_open_resize_dialog()
|
||||
7: # Updates prüfen (Selbst-Update, t_d95f087a)
|
||||
_on_update_check_menu()
|
||||
|
||||
|
||||
# ------------------------------------------------- Kartengröße ändern (R3) --
|
||||
|
|
@ -1059,6 +1077,181 @@ func _replace_doc_in_place(result: Dictionary) -> void:
|
|||
doc[key] = source
|
||||
|
||||
|
||||
# --------------------------------------------- Selbst-Update (t_d95f087a) ----
|
||||
|
||||
## Banner „🔄 Update verfügbar“ (unaufdringliche Leiste unter der Top-Bar) —
|
||||
## nur sichtbar, wenn ein Check einen Update findet. Offline bleibt alles still.
|
||||
func _update_banner_build(parent: Container) -> void:
|
||||
update_banner = PanelContainer.new()
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.36, 0.25, 0.06, 0.97) # gedämpftes Bernstein
|
||||
style.border_color = Color(0.95, 0.72, 0.15)
|
||||
style.set_border_width_all(2)
|
||||
style.set_content_margin_all(8)
|
||||
style.set_corner_radius_all(6)
|
||||
update_banner.add_theme_stylebox_override("panel", style)
|
||||
var h := HBoxContainer.new()
|
||||
h.add_theme_constant_override("separation", 14)
|
||||
update_banner.add_child(h)
|
||||
update_label = Label.new()
|
||||
update_label.add_theme_font_size_override("font_size", 17)
|
||||
h.add_child(update_label)
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
h.add_child(spacer)
|
||||
update_btn = Button.new()
|
||||
update_btn.text = "🔄 Jetzt updaten"
|
||||
update_btn.custom_minimum_size = Vector2(190, 40)
|
||||
update_btn.tooltip_text = "Lädt die neue Version (git pull), importiert sie und startet den Editor neu. Bei ungespeicherten Änderungen fragt der Editor vorher."
|
||||
update_btn.pressed.connect(_on_update_now)
|
||||
h.add_child(update_btn)
|
||||
update_banner.visible = false
|
||||
parent.add_child(update_banner)
|
||||
|
||||
|
||||
## Still Start-Check (nur Banner bei „Update verfügbar“ — offline: nichts).
|
||||
func _startup_update_check() -> void:
|
||||
_updater_check_async(false)
|
||||
|
||||
|
||||
## Datei-Menü „🔄 Updates prüfen“ (ID 7): gleicher Check mit Ergebnis-Meldung.
|
||||
func _on_update_check_menu() -> void:
|
||||
if _update_check_busy:
|
||||
_set_status("Update-Check läuft schon…")
|
||||
return
|
||||
_set_status("Prüfe auf Updates…")
|
||||
_updater_check_async(true)
|
||||
|
||||
|
||||
func _updater_check_async(report: bool) -> void:
|
||||
_update_check_busy = true
|
||||
check_thread = Thread.new()
|
||||
check_thread.start(_updater_check_thread.bind(report))
|
||||
|
||||
|
||||
func _updater_check_thread(report: bool) -> void:
|
||||
var result := EditorUpdater.check_update(
|
||||
EditorUpdater.git_exe(TestRun.load_cfg()),
|
||||
ProjectSettings.globalize_path("res://"))
|
||||
_update_result = result
|
||||
call_deferred("_updater_check_done", result, report)
|
||||
|
||||
|
||||
func _updater_check_done(result: Dictionary, report: bool) -> void:
|
||||
_update_check_busy = false
|
||||
_join(check_thread)
|
||||
check_thread = null
|
||||
match str(result.get("status")):
|
||||
"update_available":
|
||||
_show_update_banner(str(result["local"]), str(result["remote"]))
|
||||
if report:
|
||||
_set_status("🔄 Update verfügbar: %s → %s — im Banner „Jetzt updaten“ klicken." % [
|
||||
EditorUpdater.short_sha(str(result["local"])),
|
||||
EditorUpdater.short_sha(str(result["remote"]))])
|
||||
"up_to_date":
|
||||
if report:
|
||||
_set_status("Du bist auf dem neusten Stand (%s)." % EditorUpdater.short_sha(str(result["local"])))
|
||||
"unreachable":
|
||||
if report:
|
||||
_set_status("Update-Check nicht erreichbar (offline oder Tunnel weg) — später nochmal probieren.", true)
|
||||
_:
|
||||
if report:
|
||||
_set_status("Update-Check fehlgeschlagen: %s" % str(result.get("error", "")), true)
|
||||
|
||||
|
||||
func _show_update_banner(local_sha: String, remote_sha: String) -> void:
|
||||
update_label.text = EditorUpdater.banner_text(local_sha, remote_sha)
|
||||
update_banner.visible = true
|
||||
|
||||
|
||||
## Thread sauber einsammeln (Ergebnis kam per call_deferred — der Thread ist
|
||||
## längst fertig, join ist nur Ordnung).
|
||||
func _join(thread: Thread) -> void:
|
||||
if thread != null and thread.is_started():
|
||||
thread.wait_to_finish()
|
||||
|
||||
|
||||
## Banner-Knopf „Jetzt updaten“: Dirty-Schutz wie Strg+O/Strg+N (derselbe
|
||||
## deutsche Dialog — „Abbrechen“ bricht das Update ab), dann git pull +
|
||||
## Import-Lauf + Neustart. Import-Fehler = Abbruch mit Meldung, KEIN Neustart
|
||||
## (Lektion 17.10.: sonst sieht Sly einen Editor ohne Map-Ansicht).
|
||||
func _on_update_now() -> void:
|
||||
if _update_running:
|
||||
_set_status("Ein Update läuft schon…")
|
||||
return
|
||||
_request_lossy(_start_update)
|
||||
|
||||
|
||||
func _start_update() -> void:
|
||||
_update_running = true
|
||||
update_btn.disabled = true
|
||||
_set_status("Update läuft — neue Version wird geladen (git pull)…")
|
||||
update_thread = Thread.new()
|
||||
update_thread.start(_updater_run_thread)
|
||||
|
||||
|
||||
func _updater_run_thread() -> void:
|
||||
var project_dir := ProjectSettings.globalize_path("res://")
|
||||
var cfg := TestRun.load_cfg()
|
||||
var pull := EditorUpdater.pull(EditorUpdater.git_exe(cfg), project_dir)
|
||||
if not bool(pull["ok"]):
|
||||
call_deferred("_update_finished", false, "Update abgebrochen — git pull fehlgeschlagen:", pull["errors"])
|
||||
return
|
||||
call_deferred("_set_status", "Update geladen — Version wird importiert (Godot-Import-Lauf)…")
|
||||
var godot := TestRun.find_godot(cfg)
|
||||
if godot.is_empty():
|
||||
call_deferred("_update_finished", false, "Update abgebrochen — kein Godot-Programm für den Import-Lauf:", [
|
||||
"Der Editor läuft nicht über ein Godot-Binary und es ist kein godot_path gesetzt.",
|
||||
"Trage in scopedvr_editor.cfg (Godot-App-Daten) ein:",
|
||||
" [test]",
|
||||
" godot_path=\"/pfad/zu/godot\"",
|
||||
])
|
||||
return
|
||||
var imp := EditorUpdater.run_import(godot, project_dir)
|
||||
if not bool(imp["ok"]):
|
||||
call_deferred("_update_finished", false, "Update ABGEBROCHEN — der Import ist fehlgeschlagen, es wird NICHT neu gestartet:", imp["errors"])
|
||||
return
|
||||
call_deferred("_update_finished", true, "", [])
|
||||
|
||||
|
||||
func _update_finished(ok: bool, title: String, details: Array) -> void:
|
||||
_update_running = false
|
||||
update_btn.disabled = false
|
||||
_join(update_thread)
|
||||
update_thread = null
|
||||
if not ok:
|
||||
update_banner.visible = true
|
||||
_show_errors(title, details)
|
||||
_set_status("Update fehlgeschlagen — der Editor bleibt unverändert (es ist nichts kaputt gemacht).", true)
|
||||
return
|
||||
var restart := EditorUpdater.resolve_restart(TestRun.load_cfg())
|
||||
var project_dir := ProjectSettings.globalize_path("res://")
|
||||
var pid := -1
|
||||
if str(restart["mode"]) == "command":
|
||||
var argv: PackedStringArray = restart["argv"]
|
||||
pid = OS.create_process(str(argv[0]), argv.slice(1))
|
||||
else:
|
||||
var godot := TestRun.find_godot(TestRun.load_cfg())
|
||||
if godot.is_empty():
|
||||
_show_errors("Update installiert — Neustart nicht möglich:", [
|
||||
"Der neue Stand ist geladen, aber das Godot-Programm für den Neustart ist unbekannt.",
|
||||
"Editor einfach einmal selbst neu starten.",
|
||||
])
|
||||
_set_status("Update installiert — bitte den Editor einmal selbst neu starten.")
|
||||
return
|
||||
pid = OS.create_process(godot, EditorUpdater.self_restart_args(project_dir))
|
||||
if pid <= 0:
|
||||
_show_errors("Update installiert — Neustart fehlgeschlagen:", [
|
||||
"Der neue Stand ist geladen, aber der Neustart wurde vom System abgelehnt.",
|
||||
"Editor einfach einmal selbst neu starten.",
|
||||
])
|
||||
_set_status("Update installiert — bitte den Editor einmal selbst neu starten.")
|
||||
return
|
||||
_set_status("Update installiert (%s) — Editor startet neu…" % EditorUpdater.short_sha(str(_update_result.get("remote", ""))))
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
## „▶ Karte testen" (Karte t_b84af40f): aktuelle Map (auch ungespeichert!)
|
||||
## nach Temp exportieren und den scopedvr-client als EIGENES Fenster starten.
|
||||
## Der Editor bleibt offen; das Spiel beendet sich selbst sauber (ESC).
|
||||
|
|
|
|||
199
tests/test_updater.gd
Normal file
199
tests/test_updater.gd
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
extends SceneTree
|
||||
## Editor-Tests (headless): Selbst-Update — Karte t_d95f087a.
|
||||
## Ausführen: godot --headless --path . --script res://tests/test_updater.gd
|
||||
## Exit 0 = alles grün. Läuft OHNE UI (EditorUpdater + echte git-Repos in
|
||||
## user://test_updater):
|
||||
## 1. Check-Funktion mit ECHTEN Repos: gleich (up_to_date), divergiert
|
||||
## (update_available), nicht erreichbar (unreachable) — kein Mock, echte
|
||||
## git-Prozesse gegen ein Bare-Repo / gegen einen abgelehnten Port.
|
||||
## 2. Banner-Logik (Text, kurze SHAs).
|
||||
## 3. Import-Abbruch-Pfad: faked Import-Fehler (Exit-Code != 0 UND Parse-
|
||||
## -Error-Schreiber mit Exit 0) → kein ok, kein Restart (Restart wird nur
|
||||
## NACH ok angestoßen — hier wird bewusst NUR der ok-Pfad geprüft).
|
||||
## 4. Restart-Auflösung: cfg-Kommando gewinnt, Default ist self/command.
|
||||
|
||||
var failures := 0
|
||||
var checks := 0
|
||||
|
||||
const TEST_DIR_PREFIX := "user://test_updater"
|
||||
var base := ""
|
||||
var work := ""
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_cleanup_old()
|
||||
base = ProjectSettings.globalize_path(
|
||||
TEST_DIR_PREFIX + "_" + str(Time.get_ticks_msec()))
|
||||
DirAccess.make_dir_recursive_absolute(base)
|
||||
_run_tests()
|
||||
_cleanup_dir(base) # best effort — git-Pack-Dateien können read-only sein
|
||||
if failures == 0:
|
||||
print("ALL OK: %d checks" % checks)
|
||||
quit(0)
|
||||
else:
|
||||
print("FAILED: %d/%d checks" % [failures, checks])
|
||||
quit(1)
|
||||
|
||||
|
||||
func _check(cond: bool, label: String) -> void:
|
||||
checks += 1
|
||||
if cond:
|
||||
print(" ok: %s" % label)
|
||||
else:
|
||||
failures += 1
|
||||
print(" FAIL: %s" % label)
|
||||
|
||||
|
||||
## Best effort: alte Läufe aufräumen (git macht Pack-Dateien read-only —
|
||||
## dann bleibt der Ordner liegen; der LAUF nutzt deshalb IMMER ein frisches,
|
||||
## Zeit-stempel-basiertes Verzeichnis und hängt nie an Überbleibseln).
|
||||
func _cleanup_old() -> void:
|
||||
var root := DirAccess.open(ProjectSettings.globalize_path("user://"))
|
||||
if root == null:
|
||||
return
|
||||
for name in root.get_directories():
|
||||
if name.begins_with("test_updater"):
|
||||
_cleanup_dir(ProjectSettings.globalize_path("user://").path_join(name))
|
||||
|
||||
|
||||
func _cleanup_dir(base_dir: String) -> void:
|
||||
if not DirAccess.dir_exists_absolute(base_dir):
|
||||
return
|
||||
var d := DirAccess.open(base_dir)
|
||||
if d == null:
|
||||
return
|
||||
for file in d.get_files():
|
||||
DirAccess.remove_absolute(base_dir.path_join(file))
|
||||
for sub in d.get_directories():
|
||||
_cleanup_dir(base_dir.path_join(sub))
|
||||
DirAccess.remove_absolute(base_dir)
|
||||
|
||||
|
||||
func _git(dir: String, args: Array) -> Dictionary:
|
||||
var packed := PackedStringArray()
|
||||
for a in args:
|
||||
packed.append(str(a))
|
||||
return EditorUpdater.git_run("git", dir, packed)
|
||||
|
||||
|
||||
func _write_file(path: String, text: String) -> void:
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
f.store_string(text)
|
||||
f.close()
|
||||
|
||||
|
||||
func _run_tests() -> void:
|
||||
var git := "git"
|
||||
|
||||
# --- echte Repos aufbauen: origin.git (bare) + work (Klon mit main) ---
|
||||
var origin := base.path_join("origin.git")
|
||||
_check(bool(_git(base, ["init", "--bare", "--initial-branch=main", origin])["ok"]),
|
||||
"Setup: Bare-Repo origin.git angelegt")
|
||||
work = base.path_join("work")
|
||||
_check(bool(_git(base, ["init", "--initial-branch=main", work])["ok"]),
|
||||
"Setup: Arbeits-Klon angelegt")
|
||||
_check(bool(_git(work, ["remote", "add", "origin", origin])["ok"]),
|
||||
"Setup: remote origin gesetzt")
|
||||
_git(work, ["config", "user.email", "test@example.local"])
|
||||
_git(work, ["config", "user.name", "Update-Test"])
|
||||
_write_file(work.path_join("a.txt"), "Version 1\n")
|
||||
_check(bool(_git(work, ["add", "a.txt"])["ok"]), "Setup: Datei gestagt")
|
||||
_check(bool(_git(work, ["commit", "-m", "v1", "a.txt"])["ok"]), "Setup: Commit v1")
|
||||
_check(bool(_git(work, ["push", "-u", "origin", "main"])["ok"]),
|
||||
"Setup: main nach origin gepusht")
|
||||
|
||||
var local_sha := EditorUpdater.head_sha(git, work)
|
||||
_check(local_sha.length() == 40, "head_sha: 40-Zeichen-SHA gelesen")
|
||||
|
||||
print("== Check: auf dem neusten Stand ==")
|
||||
var up := EditorUpdater.check_update(git, work)
|
||||
_check(str(up["status"]) == "up_to_date", "gleiches SHA → up_to_date")
|
||||
_check(str(up["local"]) == local_sha and str(up["remote"]) == local_sha,
|
||||
"beide SHAs befüllt und identisch")
|
||||
|
||||
print("== Check: Update verfügbar ==")
|
||||
# Fremder Klon pusht einen neuen Commit nach origin — work bleibt hinten.
|
||||
var other := base.path_join("other")
|
||||
_check(bool(_git(base, ["clone", "-q", origin, other])["ok"]), "Setup: zweiter Klon")
|
||||
_git(other, ["config", "user.email", "test@example.local"])
|
||||
_git(other, ["config", "user.name", "Update-Test"])
|
||||
_write_file(other.path_join("b.txt"), "neue Version\n")
|
||||
_git(other, ["add", "b.txt"])
|
||||
_git(other, ["commit", "-m", "v2", "b.txt"])
|
||||
_check(bool(_git(other, ["push", "origin", "main"])["ok"]), "Setup: v2 gepusht")
|
||||
var avail := EditorUpdater.check_update(git, work)
|
||||
_check(str(avail["status"]) == "update_available", "origin voraus → update_available")
|
||||
_check(str(avail["local"]) == local_sha, "lokaler SHA bleibt (Pull ist NICHT Teil des Checks)")
|
||||
_check(str(avail["remote"]) != local_sha, "remote SHA ist der neue")
|
||||
|
||||
print("== Banner-Logik ==")
|
||||
var text := EditorUpdater.banner_text(local_sha, str(avail["remote"]))
|
||||
_check(text.contains("Update verfügbar"), "Banner-Text enthält „Update verfügbar“")
|
||||
_check(text.contains(local_sha.substr(0, 7)), "Banner-Text enthält kurzen ALT-SHA")
|
||||
_check(text.contains(EditorUpdater.short_sha(str(avail["remote"]))), "Banner-Text enthält kurzen NEU-SHA")
|
||||
_check(EditorUpdater.short_sha(local_sha).length() == 7, "short_sha = 7 Zeichen")
|
||||
|
||||
print("== Check: nicht erreichbar ==")
|
||||
# Port 1 (bewusst zu) → Verbindung sofort abgelehnt = der Offline-Pfad.
|
||||
_git(work, ["remote", "set-url", "origin", "http://127.0.0.1:1/nicht-da.git"])
|
||||
var dead := EditorUpdater.check_update(git, work)
|
||||
_check(str(dead["status"]) == "unreachable", "Origin tot → unreachable (still, kein Fehler)")
|
||||
_check(str(dead["local"]) == local_sha, "lokaler SHA trotzdem bekannt")
|
||||
# Falsches Binary → error (kein Repo-Check möglich)
|
||||
var bad := EditorUpdater.check_update("gibt-es-nicht-xyz", work)
|
||||
_check(str(bad["status"]) == "error", "git nicht gefunden → error (nicht unreachable)")
|
||||
|
||||
print("== Pull ==")
|
||||
_git(work, ["remote", "set-url", "origin", origin])
|
||||
var pulled := EditorUpdater.pull(git, work)
|
||||
_check(bool(pulled["ok"]), "pull --ff-only erfolgreich (v2 wird geladen)")
|
||||
_check(EditorUpdater.head_sha(git, work) == str(avail["remote"]), "nach Pull ist work auf v2")
|
||||
_git(work, ["remote", "set-url", "origin", "http://127.0.0.1:1/nicht-da.git"])
|
||||
var dead_pull := EditorUpdater.pull(git, work)
|
||||
_check(not bool(dead_pull["ok"]), "pull gegen toten Origin schlägt fehl")
|
||||
_check((dead_pull["errors"] as Array).size() > 0, "Pull-Fehlerliste fürs Dialog-Fenster")
|
||||
_git(work, ["remote", "set-url", "origin", origin])
|
||||
|
||||
print("== Import-Abbruch-Pfad ==")
|
||||
_check(EditorUpdater.import_errors([]).is_empty(), "leere Import-Ausgabe = keine Fehler")
|
||||
_check(EditorUpdater.import_errors(["Eine normale Zeile"]).is_empty(), "normale Ausgabe = keine Fehler")
|
||||
var errs := EditorUpdater.import_errors(["Parse Error: Identifier not declared in the current scope."])
|
||||
_check(errs.size() == 1, "Parse Error wird erkannt (Exit-Code-0-Falle, Lektion 17.10.)")
|
||||
errs = EditorUpdater.import_errors(["SCRIPT ERROR: blub", "und weiter"])
|
||||
_check(errs.size() == 1, "SCRIPT ERROR wird erkannt")
|
||||
# Echter Run mit Exit-Code != 0 (Benutzung von "false": endet mit 1).
|
||||
if FileAccess.file_exists("/bin/false"):
|
||||
var failed := EditorUpdater.run_import("/bin/false", work)
|
||||
_check(not bool(failed["ok"]), "Import mit Exit-Code 1 = NICHT ok (kein Restart)")
|
||||
_check((failed["errors"] as Array).size() > 0, "Import-Fehlerliste befüllt")
|
||||
# Echter Run, Exit-Code 0, ABER Parse-Error in der Ausgabe (die 17.10.-Falle):
|
||||
if FileAccess.file_exists("/bin/sh"):
|
||||
var fake := base.path_join("fake_godot.sh")
|
||||
_write_file(fake, "#!/bin/sh\necho \"Parse Error: Identifier not declared.\"\nexit 0\n")
|
||||
var code := OS.execute("chmod", PackedStringArray(["+x", fake]))
|
||||
if code == 0:
|
||||
var trap := EditorUpdater.run_import(fake, work)
|
||||
_check(not bool(trap["ok"]), "Import „grün“ MIT Parse-Error = NICHT ok (kein Restart!)")
|
||||
|
||||
print("== Restart-Auflösung ==")
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value("update", "restart_command", "echo hallo welt")
|
||||
var resolved := EditorUpdater.resolve_restart(cfg)
|
||||
_check(str(resolved["mode"]) == "command", "cfg-Kommando gewinnt (mode command)")
|
||||
var argv: PackedStringArray = resolved["argv"]
|
||||
var want := ["echo", "hallo", "welt"]
|
||||
var ok := argv.size() == want.size()
|
||||
if ok:
|
||||
for i in want.size():
|
||||
if str(argv[i]) != want[i]:
|
||||
ok = false
|
||||
_check(ok, "Kommando sauber an Leerzeichen gesplittet")
|
||||
var resolved2 := EditorUpdater.resolve_restart(ConfigFile.new())
|
||||
_check(str(resolved2["mode"]) == "self" or str(resolved2["mode"]) == "command",
|
||||
"ohne cfg: plausibler Default (self ODER systemd-Unit erkannt)")
|
||||
if str(resolved2["mode"]) == "self":
|
||||
_check((resolved2["argv"] as PackedStringArray).is_empty(),
|
||||
"mode self: UI baut die Selbst-Restart-Argumente selbst")
|
||||
var self_args := EditorUpdater.self_restart_args("/pfad/zum/klon")
|
||||
_check(str(self_args[0]) == "--editor" and str(self_args[2]) == "/pfad/zum/klon",
|
||||
"Selbst-Restart: --editor --path <klon> (sonst startet das Projekt als SPIEL)")
|
||||
1
tests/test_updater.gd.uid
Normal file
1
tests/test_updater.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cfxxi8pjnihka
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
cd "$(dirname "$0")/.."
|
||||
GODOT=~/bin/godot4
|
||||
rc=0
|
||||
for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_resize_dialog test_gui_smoke test_rmb_vorrang; do
|
||||
for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_resize_dialog test_updater test_gui_smoke test_rmb_vorrang; do
|
||||
"$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1
|
||||
code=$?
|
||||
line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)
|
||||
|
|
|
|||
Loading…
Reference in a new issue