Files
aza/AzA march 2026/aza_updater.py
2026-05-23 21:31:34 +02:00

601 lines
18 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 shutil
import subprocess
import sys
import threading
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk
from typing import Any
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,
format_version_label,
get_install_dir,
get_project_root,
get_test_install_dir,
list_available_backups,
load_local_version,
load_manifest_from_source,
rollback_from_backup,
save_local_version,
validate_update_install_ready,
)
def _log(msg: str) -> None:
print(f"[AZA Updater] {msg}")
def check_update_status(*, startup: bool = False) -> dict[str, Any]:
"""Manifest pruefen — startup=True nutzt nur Update-Kanal (kein Legacy-Fallback)."""
if startup:
return check_update_for_startup()
local = load_local_version()
remote, err = fetch_remote_manifest()
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() -> None:
if sys.platform != "win32":
return
for name in ("aza_desktop.exe", "aza_controller.exe", "AZA_EmpfangShell.exe"):
try:
subprocess.run(
["taskkill", "/F", "/IM", name],
capture_output=True,
text=True,
timeout=15,
)
except Exception:
pass
def _apply_downloaded_update(package: Path, result: dict[str, Any]) -> tuple[bool, str]:
install_dir = get_install_dir()
backup_dir = create_pre_update_backup(install_dir)
_log(f"Backup erstellt: {backup_dir}")
suffix = package.suffix.lower()
if suffix == ".zip":
ok, msg = extract_update_zip(package, install_dir)
if not ok:
rollback_from_backup(backup_dir, install_dir)
return False, f"Installation fehlgeschlagen.\nRollback durchgefuehrt."
elif suffix == ".exe":
target = install_dir / package.name
try:
import shutil
shutil.copy2(package, target)
except Exception as exc:
rollback_from_backup(backup_dir, install_dir)
return False, f"Installation fehlgeschlagen.\nRollback durchgefuehrt."
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})
except Exception as exc:
_log(f"version.json write failed: {exc}")
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 _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, 8))
ttk.Label(
frm,
text="Möchten Sie das Update jetzt installieren?",
font=("Segoe UI", 10),
wraplength=360,
).pack(anchor="w", pady=(0, 6))
ttk.Label(
frm,
text="Ihre aktuellen Einstellungen und Praxisdaten bleiben erhalten.",
font=("Segoe UI", 9),
foreground="#5a6d7d",
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 maybe_prompt_update_on_startup(*, parent: tk.Misc | None = None) -> 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
install_now = _show_friendly_update_dialog(result, parent=parent)
if not install_now:
_log("startup update deferred by user")
return
ready, _reason = validate_update_install_ready(result)
if not ready:
if parent is not None:
messagebox.showinfo(
"Update",
"Das Update-Modul wird vorbereitet.\n"
"Bitte starten Sie die Aktualisierung spaeter erneut.",
parent=parent,
)
else:
root = tk.Tk()
root.withdraw()
messagebox.showinfo(
"Update",
"Das Update-Modul wird vorbereitet.\n"
"Bitte starten Sie die Aktualisierung spaeter erneut.",
parent=root,
)
root.destroy()
return
ok, msg = perform_confirmed_update(result, parent=parent)
if parent is not None:
if ok:
messagebox.showinfo("Update", msg, parent=parent)
else:
messagebox.showinfo("Update", msg, parent=parent)
else:
root = tk.Tk()
root.withdraw()
messagebox.showinfo("Update", msg, parent=root)
root.destroy()
def run_startup_update_check_in_background(*, delay_seconds: float = 1.2) -> None:
"""Nicht-blockierende Startpruefung fuer aza_start_panel.py."""
def worker() -> None:
import time
time.sleep(max(0.0, delay_seconds))
try:
maybe_prompt_update_on_startup()
except Exception as exc:
_log(f"startup check skipped: {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 = json.loads((root / "version.json").read_text(encoding="utf-8-sig"))
report["sha256_verified"] = report.get("update_applied", False)
report["project_root_version"] = project_version.get("version")
report["project_root_unchanged"] = project_version.get("version") == "1.2.0"
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-Pfad fuer manuelle Pruefung",
)
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()
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
if args.install_dir:
configure_test_install_dir(args.install_dir)
if args.manifest and args.yes:
result = check_update_from_manifest(args.manifest)
ok, msg = perform_confirmed_update(result)
print(msg)
return 0 if ok else 1
owns = False
parent: tk.Misc | None = 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())