Files
aza/AzA march 2026/backup_update_dialog_modernui_20260524_084657/aza_updater.py
2026-05-28 18:58:38 +02:00

1115 lines
35 KiB
Python

# -*- coding: utf-8 -*-
"""
AZA Desktop Updater — Startprompt + sichere Installation.
Beim Startpanel-Start: freundliche Nachfrage bei neuer Version.
Kein sichtbarer Update-Button, kein stilles Auto-Update.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import threading
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk
from typing import Any, Callable
from aza_update_core import (
check_update_for_startup,
check_update_from_manifest,
configure_test_install_dir,
create_pre_update_backup,
compute_sha256,
download_update_package,
evaluate_update,
extract_update_zip,
fetch_remote_manifest,
fetch_startup_manifest,
format_version_label,
get_install_dir,
get_project_root,
get_test_install_dir,
list_available_backups,
load_local_version,
load_manifest_from_source,
log_update_check,
resolve_manifest_sources,
rollback_from_backup,
save_local_version,
validate_update_install_ready,
)
def _log(msg: str) -> None:
print(f"[AZA Updater] {msg}")
log_update_check("updater", message=msg)
def _apply_cli_env(install_dir: str | None = None) -> None:
if install_dir:
configure_test_install_dir(install_dir)
return
env_dir = (os.environ.get("AZA_UPDATE_TEST_INSTALL_DIR") or "").strip()
if env_dir:
configure_test_install_dir(env_dir)
def check_update_status(
*,
startup: bool = False,
manifest: str | None = None,
) -> dict[str, Any]:
"""Manifest pruefen — startup=True fuer Startpanel, manifest= fuer CLI/Env."""
if manifest:
return check_update_from_manifest(manifest)
if startup:
env_manifest = (os.environ.get("AZA_UPDATE_MANIFEST_URL") or "").strip() or None
return check_update_for_startup(manifest=env_manifest)
local = load_local_version()
sources = resolve_manifest_sources()
remote, err = fetch_startup_manifest(sources=sources)
if err or not remote:
return {
"status": "error",
"message": "Manifest nicht erreichbar.",
"detail": err,
"local": local,
}
result = evaluate_update(local, remote)
result["detail"] = err
return result
def _stop_aza_processes(*, include_panel: bool = False) -> None:
if sys.platform != "win32":
return
names = [
"aza_desktop.exe",
"aza_controller.exe",
"AZA_EmpfangShell.exe",
"aza_office.exe",
]
if include_panel:
names.append("aza_start_panel.exe")
for name in names:
try:
subprocess.run(
["taskkill", "/F", "/IM", name],
capture_output=True,
text=True,
timeout=15,
)
except Exception:
pass
def _is_dir_writable(path: Path) -> bool:
try:
path.mkdir(parents=True, exist_ok=True)
probe = path / f".aza_write_test_{os.getpid()}"
probe.write_text("x", encoding="utf-8")
try:
probe.unlink()
except OSError:
pass
return True
except Exception:
return False
def _resolve_installed_updater_exe(target_install: Path) -> Path | None:
cand = target_install / "aza_updater.exe"
if cand.is_file():
return cand
if getattr(sys, "frozen", False):
here = Path(sys.executable).resolve().parent
cand2 = here / "aza_updater.exe"
if cand2.is_file():
return cand2
return None
def _copy_updater_to_temp(src_exe: Path) -> Path | None:
import tempfile
temp_root = Path(tempfile.gettempdir()) / "AzA_Updater_Run"
try:
temp_root.mkdir(parents=True, exist_ok=True)
except Exception as exc:
_log(f"temp_root mkdir failed: {type(exc).__name__}")
return None
dst = temp_root / "aza_updater.exe"
try:
if dst.is_file():
try:
dst.unlink()
except Exception:
pass
shutil.copy2(src_exe, dst)
return dst
except Exception as exc:
_log(f"copy_updater_to_temp failed: {type(exc).__name__}")
return None
def launch_external_installer(
result: dict[str, Any],
*,
install_dir: Path | None = None,
manifest_url: str | None = None,
restart_exe: str = "aza_start_panel.exe",
) -> tuple[bool, str]:
"""
Startet aza_updater.exe als externen Prozess (UAC falls noetig)
und gibt die Kontrolle ab. Der externe Updater installiert sicher,
weil er aus %TEMP% laeuft und das Startpanel selbst ersetzen kann.
"""
target_install = (install_dir or get_install_dir()).resolve()
src_updater = _resolve_installed_updater_exe(target_install)
if src_updater is None:
log_update_check("ext_launch_no_updater", target=str(target_install))
return False, "aza_updater.exe nicht gefunden."
temp_updater = _copy_updater_to_temp(src_updater)
if temp_updater is None:
log_update_check("ext_launch_copy_failed")
return False, "Updater konnte nicht in TEMP kopiert werden."
if not manifest_url:
remote = result.get("remote") or {}
manifest_url = str(remote.get("_manifest_url") or "").strip()
if not manifest_url:
manifest_url = (os.environ.get("AZA_UPDATE_MANIFEST_URL") or "").strip()
args = ["--install-now"]
if manifest_url:
args += ["--manifest", manifest_url]
args += ["--target-dir", str(target_install)]
if restart_exe:
args += ["--restart-exe", restart_exe]
use_runas = (sys.platform == "win32") and (not _is_dir_writable(target_install))
log_update_check(
"ext_launch",
updater=str(temp_updater),
target=str(target_install),
manifest=manifest_url or "",
runas=use_runas,
)
try:
if use_runas:
import ctypes
params = " ".join(f'"{a}"' if (" " in a or a == "") else a for a in args)
ret = ctypes.windll.shell32.ShellExecuteW( # type: ignore[attr-defined]
None, "runas", str(temp_updater), params, None, 1
)
if int(ret) <= 32:
log_update_check("ext_launch_uac_failed", code=int(ret))
return False, f"UAC-Start fehlgeschlagen (code={int(ret)})"
else:
creationflags = 0
if sys.platform == "win32":
creationflags = (
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
| getattr(subprocess, "DETACHED_PROCESS", 0)
)
subprocess.Popen(
[str(temp_updater)] + args,
cwd=str(temp_updater.parent),
close_fds=True,
creationflags=creationflags,
)
except Exception as exc:
log_update_check("ext_launch_exception", error=f"{type(exc).__name__}: {exc}"[:160])
return False, f"Updater konnte nicht gestartet werden: {exc}"
return True, "ok"
def _apply_downloaded_update(package: Path, result: dict[str, Any]) -> tuple[bool, str]:
install_dir = get_install_dir()
log_update_check("apply_start", install_dir=str(install_dir), package=str(package))
try:
backup_dir = create_pre_update_backup(install_dir)
_log(f"Backup erstellt: {backup_dir}")
log_update_check("apply_backup", backup=str(backup_dir))
except Exception as exc:
log_update_check("apply_backup_failed", error=f"{type(exc).__name__}: {exc}"[:160])
return False, "Backup konnte nicht erstellt werden."
suffix = package.suffix.lower()
if suffix == ".zip":
ok, msg = extract_update_zip(package, install_dir)
log_update_check("apply_extract", ok=ok, msg=str(msg)[:120])
if not ok:
rollback_from_backup(backup_dir, install_dir)
log_update_check("apply_rollback_done")
return False, f"Installation fehlgeschlagen.\nRollback durchgefuehrt.\nDetail: {msg}"
elif suffix == ".exe":
target = install_dir / package.name
try:
shutil.copy2(package, target)
log_update_check("apply_copy_exe", target=str(target))
except Exception as exc:
log_update_check("apply_copy_failed", error=f"{type(exc).__name__}: {exc}"[:160])
rollback_from_backup(backup_dir, install_dir)
return False, f"Installation fehlgeschlagen.\nRollback durchgefuehrt.\nDetail: {exc}"
else:
return False, "Unbekanntes Update-Format."
new_version = {
"version": result.get("latest_version"),
"build": result.get("latest_build"),
"channel": (result.get("local") or {}).get("channel", "stable"),
}
try:
save_local_version({k: v for k, v in new_version.items() if v})
log_update_check("apply_save_version", version=new_version.get("version"))
except Exception as exc:
_log(f"version.json write failed: {exc}")
log_update_check("apply_savever_failed", error=f"{type(exc).__name__}: {exc}"[:160])
return True, "Das Update wurde installiert.\nBitte starten Sie AZA neu."
def perform_confirmed_update(
result: dict[str, Any],
*,
parent: tk.Misc | None = None,
) -> tuple[bool, str]:
"""Download + SHA256 + Backup + Installation — nur bei gueltigem Manifest."""
ready, reason = validate_update_install_ready(result)
if not ready:
return False, (
"Das Update ist noch nicht vollstaendig verfuegbar.\n"
"Bitte versuchen Sie es spaeter erneut."
)
files = result.get("files") or []
file_info = files[0]
_log("Download starten")
package, err = download_update_package(file_info)
if not package:
_log(f"Download fehlgeschlagen: {err}")
return False, (
"Das Update konnte nicht heruntergeladen werden.\n"
"Bitte versuchen Sie es spaeter erneut."
)
_stop_aza_processes()
return _apply_downloaded_update(package, result)
def _run_install_now_window(
*,
manifest: str,
target_dir: str | None,
restart_exe: str | None,
) -> int:
"""Externes Update-Fenster mit Statusanzeige (Download + Install)."""
import time
if target_dir:
configure_test_install_dir(target_dir)
if sys.platform == "win32" and not _is_dir_writable(get_install_dir()):
try:
import ctypes
exe = sys.executable
params = " ".join(f'"{a}"' if (" " in a or a == "") else a for a in sys.argv[1:])
ret = ctypes.windll.shell32.ShellExecuteW( # type: ignore[attr-defined]
None, "runas", exe, params, None, 1
)
if int(ret) > 32:
log_update_check("install_now_self_elevate")
return 0
log_update_check("install_now_uac_denied", code=int(ret))
except Exception as exc:
log_update_check(
"install_now_elevate_exc",
error=f"{type(exc).__name__}: {exc}"[:120],
)
log_update_check(
"install_now_start",
manifest=manifest or "",
target=target_dir or "",
restart=restart_exe or "",
)
root = tk.Tk()
root.title("AzA Update")
root.resizable(False, False)
try:
root.attributes("-topmost", True)
except Exception:
pass
frm = ttk.Frame(root, padding=22)
frm.pack(fill="both", expand=True)
ttk.Label(
frm,
text="AzA Update",
font=("Segoe UI", 13, "bold"),
).pack(anchor="w", pady=(0, 8))
status_var = tk.StringVar(value="Update wird vorbereitet …")
ttk.Label(
frm,
textvariable=status_var,
font=("Segoe UI", 10),
wraplength=360,
).pack(anchor="w", pady=(0, 12))
pb = ttk.Progressbar(frm, mode="indeterminate", length=360)
pb.pack(pady=(0, 12))
pb.start(40)
btn_row = ttk.Frame(frm)
btn_row.pack(fill="x")
close_btn = ttk.Button(btn_row, text="Schließen", state="disabled", command=root.destroy)
close_btn.pack(side="right")
root.update_idletasks()
try:
sw, sh = root.winfo_screenwidth(), root.winfo_screenheight()
root.geometry(f"+{(sw - root.winfo_reqwidth()) // 2}+{(sh - root.winfo_reqheight()) // 2}")
except Exception:
pass
state: dict[str, Any] = {"ok": False, "message": "", "done": False}
def set_status(text: str) -> None:
try:
status_var.set(text)
root.update_idletasks()
except Exception:
pass
def worker() -> None:
try:
time.sleep(2.0)
_stop_aza_processes(include_panel=True)
time.sleep(1.5)
set_status("Manifest wird gelesen …")
if manifest:
result = check_update_from_manifest(manifest)
else:
result = check_update_status(startup=False)
status = result.get("status")
log_update_check("install_now_manifest", status=status)
if status != "update_available":
state["message"] = f"Kein Update verfuegbar (Status: {status})."
return
files = result.get("files") or []
if not files:
state["message"] = "Manifest enthaelt keine Update-Datei."
return
file_info = files[0]
set_status("Update wird heruntergeladen …")
log_update_check("install_now_download_start", url=file_info.get("url", ""))
package, dl_msg = download_update_package(file_info)
log_update_check("install_now_download", ok=bool(package), msg=str(dl_msg)[:120])
if not package:
state["message"] = f"Download fehlgeschlagen: {dl_msg}"
return
set_status("AzA-Komponenten werden geschlossen …")
_stop_aza_processes(include_panel=True)
time.sleep(1.5)
set_status("Update wird installiert …")
ok, msg = _apply_downloaded_update(package, result)
log_update_check("install_now_apply", ok=ok, msg=str(msg)[:120])
state["ok"] = ok
state["message"] = msg
except Exception as exc:
log_update_check(
"install_now_exception",
error=f"{type(exc).__name__}: {exc}"[:200],
)
state["message"] = f"Fehler: {type(exc).__name__}: {exc}"
finally:
state["done"] = True
def poll() -> None:
if not state["done"]:
root.after(250, poll)
return
try:
pb.stop()
pb.configure(mode="determinate", value=100 if state["ok"] else 0)
except Exception:
pass
if state["ok"]:
set_status("Update abgeschlossen. AzA wird neu gestartet.")
log_update_check("install_now_result", ok=True)
if restart_exe:
install_dir = get_install_dir()
exe = install_dir / restart_exe
if exe.is_file():
try:
subprocess.Popen([str(exe)], cwd=str(install_dir), close_fds=True)
log_update_check("install_now_restart", exe=str(exe))
except Exception as exc:
log_update_check(
"install_now_restart_failed",
error=f"{type(exc).__name__}: {exc}"[:120],
)
close_btn.configure(state="normal")
root.after(2500, root.destroy)
else:
set_status(state["message"] or "Update fehlgeschlagen.")
log_update_check("install_now_result", ok=False, msg=str(state["message"])[:200])
close_btn.configure(state="normal")
threading.Thread(target=worker, daemon=True, name="aza-install-now").start()
root.after(300, poll)
root.mainloop()
return 0 if state["ok"] else 1
def _show_friendly_update_dialog(
result: dict[str, Any],
*,
parent: tk.Misc | None = None,
) -> bool:
"""Freundlicher Dialog — True = Jetzt aktualisieren."""
owns = False
root = parent
if root is None:
root = tk.Tk()
root.withdraw()
owns = True
choice = {"install": False}
dlg = tk.Toplevel(root)
dlg.title("Update verfügbar")
dlg.resizable(False, False)
dlg.transient(root)
dlg.grab_set()
frm = ttk.Frame(dlg, padding=18)
frm.pack(fill="both", expand=True)
ttk.Label(
frm,
text="Eine neue AzA-Version ist verfügbar.",
font=("Segoe UI", 11, "bold"),
wraplength=360,
).pack(anchor="w", pady=(0, 10))
ttk.Label(
frm,
text="Jetzt aktualisieren?",
font=("Segoe UI", 10),
wraplength=360,
).pack(anchor="w", pady=(0, 14))
btn_row = ttk.Frame(frm)
btn_row.pack(fill="x")
def on_install() -> None:
choice["install"] = True
dlg.destroy()
def on_later() -> None:
dlg.destroy()
ttk.Button(btn_row, text="Jetzt aktualisieren", command=on_install).pack(
side="left", padx=(0, 8)
)
ttk.Button(btn_row, text="Später", command=on_later).pack(side="left")
dlg.protocol("WM_DELETE_WINDOW", on_later)
dlg.update_idletasks()
try:
sw = dlg.winfo_screenwidth()
sh = dlg.winfo_screenheight()
dlg.geometry(
f"+{(sw - dlg.winfo_reqwidth()) // 2}+{(sh - dlg.winfo_reqheight()) // 2}"
)
except Exception:
pass
if owns:
dlg.wait_window()
try:
root.destroy()
except Exception:
pass
else:
root.wait_window(dlg)
return bool(choice["install"])
def _show_update_dialog_native() -> bool:
"""Windows-nativer Dialog — thread-sicher neben pywebview (kein Tk)."""
if sys.platform != "win32":
return False
import ctypes
MB_YESNO = 0x00000004
MB_ICONINFORMATION = 0x00000040
MB_TOPMOST = 0x00040000
MB_SETFOREGROUND = 0x00010000
IDYES = 6
text = "Eine neue AzA-Version ist verfügbar.\n\nJetzt aktualisieren?"
result = ctypes.windll.user32.MessageBoxW( # type: ignore[attr-defined]
0,
text,
"Update verfügbar",
MB_YESNO | MB_ICONINFORMATION | MB_TOPMOST | MB_SETFOREGROUND,
)
return int(result) == IDYES
def _notify_native(title: str, message: str) -> None:
if sys.platform != "win32":
return
import ctypes
MB_OK = 0
MB_ICONINFORMATION = 0x40
MB_TOPMOST = 0x40000
ctypes.windll.user32.MessageBoxW( # type: ignore[attr-defined]
0, message, title, MB_OK | MB_ICONINFORMATION | MB_TOPMOST
)
def _handle_update_prompt(
result: dict[str, Any],
*,
parent: tk.Misc | None = None,
use_native_dialog: bool = False,
) -> None:
"""Dialog + externer Installer — nur im UI-/Dialog-Kontext aufrufen."""
if use_native_dialog:
install_now = _show_update_dialog_native()
else:
install_now = _show_friendly_update_dialog(result, parent=parent)
if not install_now:
_log("startup update deferred by user")
log_update_check("user_deferred")
return
ready, _reason = validate_update_install_ready(result)
if not ready:
msg = (
"Das Update-Modul wird vorbereitet.\n"
"Bitte starten Sie die Aktualisierung später erneut."
)
log_update_check("install_not_ready")
if use_native_dialog:
_notify_native("Update", msg)
elif parent is not None:
messagebox.showinfo("Update", msg, parent=parent)
else:
root = tk.Tk()
root.withdraw()
messagebox.showinfo("Update", msg, parent=root)
root.destroy()
return
ok, msg = launch_external_installer(result)
log_update_check("install_launch", ok=ok, msg=str(msg)[:160])
if ok:
info = (
"Update wird vorbereitet.\n"
"AzA wird kurz geschlossen und automatisch neu gestartet."
)
try:
if use_native_dialog:
_notify_native("Update", info)
elif parent is not None:
try:
parent.after(0, lambda: messagebox.showinfo("Update", info, parent=parent))
except Exception:
pass
except Exception:
pass
try:
if parent is not None:
parent.after(400, parent.destroy)
except Exception:
pass
def _terminate() -> None:
try:
os._exit(0)
except Exception:
pass
threading.Timer(1.8, _terminate).start()
return
err = f"Update konnte nicht gestartet werden.\n{msg}"
if use_native_dialog:
_notify_native("Update", err)
elif parent is not None:
messagebox.showinfo("Update", err, parent=parent)
else:
root = tk.Tk()
root.withdraw()
messagebox.showinfo("Update", err, parent=root)
root.destroy()
def maybe_prompt_update_on_startup(
*,
parent: tk.Misc | None = None,
use_native_dialog: bool = False,
) -> None:
"""
Beim Startpanel: still pruefen, bei Update freundlich fragen.
404 / kein Manifest / aktuell => nichts anzeigen.
"""
result = check_update_status(startup=True)
status = result.get("status")
_log(f"startup status={status} detail={result.get('detail')}")
if status in ("manifest_unavailable", "current", "channel_mismatch", "error"):
return
if status != "update_available":
return
_handle_update_prompt(result, parent=parent, use_native_dialog=use_native_dialog)
def run_startup_update_check_in_background(
*,
delay_seconds: float = 1.2,
parent: tk.Misc | None = None,
use_native_dialog: bool = False,
schedule_on_main: Callable[[Callable[[], None]], None] | None = None,
) -> None:
"""
Nicht-blockierende Startpruefung fuer aza_start_panel.py.
Worker-Thread prueft nur das Manifest. Dialog/Installation erfolgt
im Main-Thread (Tk-Fallback) oder per nativem Win32-Dialog (WebView).
"""
def worker() -> None:
import time
time.sleep(max(0.0, delay_seconds))
try:
result = check_update_status(startup=True)
status = result.get("status")
_log(f"startup status={status} detail={result.get('detail')}")
if status != "update_available":
return
def prompt() -> None:
_handle_update_prompt(
result,
parent=parent,
use_native_dialog=use_native_dialog,
)
if schedule_on_main is not None:
schedule_on_main(prompt)
else:
prompt()
except Exception as exc:
_log(f"startup check skipped: {type(exc).__name__}")
log_update_check("startup_error", error=type(exc).__name__)
threading.Thread(
target=worker,
daemon=True,
name="aza-startup-update",
).start()
def check_updates_interactive(*, parent: tk.Misc | None = None) -> None:
"""Manuelle Pruefung (technisch) — fuer aza_controller.py."""
result = check_update_status(startup=False)
status = result.get("status")
if status == "update_available":
install_now = _show_friendly_update_dialog(result, parent=parent)
if install_now:
ok, msg = perform_confirmed_update(result, parent=parent)
messagebox.showinfo("Update", msg, parent=parent)
return
if status == "current":
messagebox.showinfo(
"Update",
f"AZA ist aktuell ({format_version_label()}).",
parent=parent,
)
return
messagebox.showinfo(
"Update",
"Derzeit ist kein Update verfuegbar.",
parent=parent,
)
def show_app_info(*, parent: tk.Misc | None = None) -> None:
local = load_local_version()
messagebox.showinfo(
"AzA",
f"Version: {format_version_label(local)}\n\nAzA Medwork",
parent=parent,
)
def _read_marker(install_dir: Path) -> str:
p = install_dir / "app_marker.txt"
if p.is_file():
return p.read_text(encoding="utf-8").strip()
return ""
def _reset_test_install_dir(install_dir: Path) -> None:
install_dir.mkdir(parents=True, exist_ok=True)
backups = install_dir / "_update_backups"
if backups.is_dir():
shutil.rmtree(backups, ignore_errors=True)
(install_dir / "version.json").write_text(
json.dumps(
{
"version": "1.2.0",
"build": "TEST_OLD",
"channel": "stable",
"app": "AZA Desktop",
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
(install_dir / "app_marker.txt").write_text("OLD_VERSION\n", encoding="utf-8")
for name in (
"aza_controller.exe",
"aza_office.exe",
"aza_praxis_chat.exe",
"aza_updater.exe",
):
p = install_dir / name
if not p.is_file():
p.write_bytes(b"OLD_DUMMY_EXE\n")
def _build_test_package(project_root: Path) -> tuple[Path, Path, str, int]:
import zipfile
pkg_dir = project_root / "_updater_test_package"
zip_path = project_root / "_updater_test_package.zip"
if pkg_dir.is_dir():
shutil.rmtree(pkg_dir)
pkg_dir.mkdir(parents=True)
(pkg_dir / "version.json").write_text(
json.dumps(
{
"version": "9.9.9",
"build": "TEST_LOCAL",
"channel": "stable",
"app": "AZA Desktop",
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
(pkg_dir / "app_marker.txt").write_text("NEW_VERSION\n", encoding="utf-8")
for name in (
"aza_controller.exe",
"aza_office.exe",
"aza_praxis_chat.exe",
"aza_updater.exe",
):
(pkg_dir / name).write_bytes(b"NEW_DUMMY_EXE\n")
if zip_path.is_file():
zip_path.unlink()
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for fp in pkg_dir.rglob("*"):
if fp.is_file():
zf.write(fp, fp.relative_to(pkg_dir).as_posix())
sha = compute_sha256(zip_path)
return pkg_dir, zip_path, sha, zip_path.stat().st_size
def _write_test_manifest(
project_root: Path,
zip_path: Path,
sha256: str,
size_bytes: int,
*,
bad_sha: str | None = None,
missing_sha: bool = False,
missing_zip: bool = False,
) -> Path:
from datetime import date
manifest_path = project_root / "test_update_manifest.json"
url = str(zip_path.resolve()) if not missing_zip else str(
(project_root / "_missing_update.zip").resolve()
)
entry = {
"name": zip_path.name,
"url": url,
"size_bytes": size_bytes,
}
if not missing_sha:
entry["sha256"] = bad_sha or sha256
payload = {
"product": "AZA Desktop",
"channel": "stable",
"latest_version": "9.9.9",
"latest_build": "TEST_LOCAL",
"min_supported_version": "1.0.0",
"mandatory": False,
"release_date": date.today().isoformat(),
"notes_de": ["Lokaler Updater-Test"],
"files": [entry],
}
manifest_path.write_text(
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return manifest_path
def run_local_e2e_tests(project_root: Path | None = None) -> dict[str, Any]:
"""Automatisierter lokaler End-to-End-Test ohne GUI und ohne Projektroot."""
root = (project_root or get_project_root()).resolve()
install_dir = root / "_updater_test_install"
configure_test_install_dir(install_dir)
_reset_test_install_dir(install_dir)
_, zip_path, sha256, size_bytes = _build_test_package(root)
manifest_path = _write_test_manifest(root, zip_path, sha256, size_bytes)
report: dict[str, Any] = {
"install_dir": str(install_dir),
"zip_path": str(zip_path),
"manifest_path": str(manifest_path),
"sha256": sha256,
}
result = check_update_from_manifest(manifest_path)
report["update_detected"] = result.get("status") == "update_available"
ok, msg = perform_confirmed_update(result)
report["update_applied"] = ok
report["update_message"] = msg
report["marker_after_update"] = _read_marker(install_dir)
report["version_after_update"] = load_local_version().get("version")
backups = list_available_backups()
report["backup_created"] = bool(backups)
if backups:
rb_ok, rb_msg = rollback_from_backup(backups[0], install_dir)
report["rollback_tested"] = rb_ok
report["rollback_message"] = rb_msg
report["marker_after_rollback"] = _read_marker(install_dir)
report["version_after_rollback"] = load_local_version().get("version")
_reset_test_install_dir(install_dir)
bad_manifest = _write_test_manifest(
root, zip_path, sha256, size_bytes, bad_sha="0" * 64
)
bad_result = check_update_from_manifest(bad_manifest)
bad_ok, _ = perform_confirmed_update(bad_result)
report["bad_sha_blocked"] = (not bad_ok) and _read_marker(install_dir) == "OLD_VERSION"
_reset_test_install_dir(install_dir)
no_sha_manifest = _write_test_manifest(
root, zip_path, sha256, size_bytes, missing_sha=True
)
no_sha_result = check_update_from_manifest(no_sha_manifest)
no_sha_ready, _ = validate_update_install_ready(no_sha_result)
report["missing_sha_blocked"] = not no_sha_ready
_reset_test_install_dir(install_dir)
missing_zip_manifest = _write_test_manifest(
root, zip_path, sha256, size_bytes, missing_zip=True
)
missing_result = check_update_from_manifest(missing_zip_manifest)
missing_ok, _ = perform_confirmed_update(missing_result)
report["missing_zip_blocked"] = (not missing_ok) and _read_marker(install_dir) == "OLD_VERSION"
_reset_test_install_dir(install_dir)
later_result = check_update_from_manifest(manifest_path)
report["later_unchanged"] = (
later_result.get("status") == "update_available"
and _read_marker(install_dir) == "OLD_VERSION"
and load_local_version().get("version") == "1.2.0"
)
project_version_before = json.loads((root / "version.json").read_text(encoding="utf-8-sig"))
report["project_root_version"] = project_version_before.get("version")
report["sha256_verified"] = report.get("update_applied", False)
report["project_root_unchanged"] = (
json.loads((root / "version.json").read_text(encoding="utf-8-sig")).get("version")
== project_version_before.get("version")
)
configure_test_install_dir(None)
_write_test_manifest(root, zip_path, sha256, size_bytes)
return report
def _parse_cli() -> argparse.Namespace:
import argparse
p = argparse.ArgumentParser(description="AZA Desktop Updater")
p.add_argument(
"--e2e-local-test",
action="store_true",
help="Lokalen End-to-End-Test ausfuehren (ohne GUI)",
)
p.add_argument(
"--install-dir",
help="Test-Installationsordner (setzt AZA_UPDATE_TEST_INSTALL_DIR)",
)
p.add_argument(
"--manifest",
help="Manifest-URL oder lokaler Pfad (ueberschreibt Standard-Quellen)",
)
p.add_argument(
"--check-only",
action="store_true",
help="Nur pruefen und Ergebnis als JSON ausgeben (kein Dialog)",
)
p.add_argument(
"--install-now",
action="store_true",
help="Externer Installations-Modus mit Statusfenster",
)
p.add_argument(
"--target-dir",
help="Installationsordner fuer --install-now",
)
p.add_argument(
"--restart-exe",
default="aza_start_panel.exe",
help="Nach erfolgreichem Update zu startende EXE",
)
p.add_argument(
"--yes",
action="store_true",
help="Update ohne zusaetzlichen Dialog installieren (nur Testmodus)",
)
return p.parse_args()
def main() -> int:
args = _parse_cli()
_apply_cli_env(args.install_dir)
if args.install_now:
manifest = (args.manifest or os.environ.get("AZA_UPDATE_MANIFEST_URL") or "").strip()
return _run_install_now_window(
manifest=manifest,
target_dir=args.target_dir,
restart_exe=args.restart_exe,
)
if args.e2e_local_test:
report = run_local_e2e_tests()
print(json.dumps(report, indent=2, ensure_ascii=False))
ok = all(
report.get(k)
for k in (
"update_detected",
"update_applied",
"backup_created",
"rollback_tested",
"bad_sha_blocked",
"missing_sha_blocked",
"missing_zip_blocked",
"later_unchanged",
"project_root_unchanged",
)
) and report.get("marker_after_update") == "NEW_VERSION" and report.get(
"marker_after_rollback"
) == "OLD_VERSION"
return 0 if ok else 1
manifest = (args.manifest or os.environ.get("AZA_UPDATE_MANIFEST_URL") or "").strip() or None
if manifest and args.check_only:
result = check_update_from_manifest(manifest)
local = result.get("local") or load_local_version()
log_update_check(
"cli_check",
status=result.get("status"),
manifest_url=manifest,
local_version=local.get("version"),
)
print(json.dumps(result, indent=2, ensure_ascii=False, default=str))
return 0
if manifest and args.yes:
result = check_update_from_manifest(manifest)
ok, msg = perform_confirmed_update(result)
print(msg)
return 0 if ok else 1
if manifest:
result = check_update_from_manifest(manifest)
status = result.get("status")
owns = False
parent: tk.Misc | None = None
if not tk._default_root: # type: ignore[attr-defined]
parent = tk.Tk()
parent.withdraw()
owns = True
if status == "update_available":
if _show_friendly_update_dialog(result, parent=parent):
ok, msg = perform_confirmed_update(result, parent=parent)
messagebox.showinfo("Update", msg, parent=parent)
elif status == "current":
messagebox.showinfo(
"Update",
f"AZA ist aktuell ({format_version_label()}).",
parent=parent,
)
else:
messagebox.showinfo(
"Update",
f"Status: {status}\n{result.get('message', '')}",
parent=parent,
)
if owns and parent is not None:
parent.destroy()
return 0
owns = False
parent = None
if not tk._default_root: # type: ignore[attr-defined]
parent = tk.Tk()
parent.withdraw()
owns = True
check_updates_interactive(parent=parent)
if owns and parent is not None:
parent.destroy()
return 0
if __name__ == "__main__":
raise SystemExit(main())