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

266 lines
8.2 KiB
Python

# -*- coding: utf-8 -*-
"""
AZA Desktop Updater — Phase 1.
Prueft Updates ueber das Server-Manifest, laedt bei Bestaetigung herunter,
prueft SHA256, sichert vor Installation und bietet Rollback an.
Kein stilles Auto-Update.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk
from typing import Any
from aza_update_core import (
create_pre_update_backup,
download_update_package,
evaluate_update,
extract_update_zip,
fetch_remote_manifest,
format_version_label,
get_project_root,
list_available_backups,
load_local_version,
rollback_from_backup,
save_local_version,
)
def _log(msg: str) -> None:
print(f"[AZA Updater] {msg}")
def check_update_status() -> dict[str, Any]:
"""Nur pruefen — kein Download, keine UI."""
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["fetch_error"] = err
return result
def _status_title(result: dict[str, Any]) -> str:
status = result.get("status")
if status == "update_available":
return "Update verfuegbar"
if status == "current":
return "AZA ist aktuell"
if status == "channel_mismatch":
return "Update-Kanal abweichend"
return "Update-Pruefung"
def _status_message(result: dict[str, Any]) -> str:
status = result.get("status")
local = result.get("local") or load_local_version()
local_label = format_version_label(local)
if status == "error":
detail = result.get("detail") or result.get("message") or "Unbekannter Fehler"
return (
f"Lokal: {local_label}\n\n"
f"Das Update-Manifest konnte nicht geladen werden.\n"
f"Details: {detail}\n\n"
"AZA laeuft weiter — es wurde nichts installiert."
)
if status == "current":
remote = result.get("remote") or {}
remote_ver, remote_build, _ = (
str(remote.get("latest_version") or remote.get("version") or "?"),
str(remote.get("latest_build") or remote.get("build") or ""),
"",
)
remote_label = f"v{remote_ver}"
if remote_build:
remote_label += f" · {remote_build}"
return (
f"Lokal: {local_label}\n"
f"Server: {remote_label}\n\n"
"Kein Update erforderlich."
)
if status == "channel_mismatch":
return (
f"Lokal: {local_label}\n\n"
f"{result.get('message', 'Kanal stimmt nicht ueberein.')}\n\n"
"Es wurde nichts installiert."
)
if status == "update_available":
latest = result.get("latest_version") or "?"
latest_build = result.get("latest_build") or ""
lines = [
f"Lokal: {local_label}",
f"Angebot: v{latest}" + (f" · {latest_build}" if latest_build else ""),
"",
"Ein Update ist verfuegbar.",
"Moechten Sie es jetzt herunterladen und installieren?",
]
notes = result.get("notes") or []
if notes:
lines.extend(["", "Aenderungen:"])
lines.extend(f"{n}" for n in notes[:8])
return "\n".join(lines)
return str(result.get("message") or "Unbekannter Status.")
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_project_root()
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: {msg}\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"Installer konnte nicht kopiert werden: {exc}\nRollback durchgefuehrt."
else:
return False, f"Unbekanntes Update-Format: {package.name}"
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 konnte nicht geschrieben werden: {exc}")
return True, f"Update installiert.\nBackup: {backup_dir}"
def perform_confirmed_update(
result: dict[str, Any],
*,
parent: tk.Misc | None = None,
) -> tuple[bool, str]:
"""Laedt das erste Manifest-Paket herunter und installiert nach Bestaetigung."""
files = result.get("files") or []
if not files:
return False, "Kein Update-Paket im Manifest."
file_info = files[0]
_log(f"Download starten: {file_info.get('url')}")
package, err = download_update_package(file_info)
if not package:
return False, f"Download fehlgeschlagen: {err}"
confirm = messagebox.askyesno(
"AZA — Update installieren",
(
f"Paket: {package.name}\n"
f"Groesse: {package.stat().st_size:,} Bytes\n\n"
"Laufende AZA-Prozesse werden beendet.\n"
"Die aktuelle Installation wird gesichert.\n\n"
"Jetzt installieren?"
),
parent=parent,
)
if not confirm:
return False, "Installation abgebrochen — Download liegt lokal vor, wurde nicht installiert."
_stop_aza_processes()
ok, msg = _apply_downloaded_update(package, result)
return ok, msg
def check_updates_interactive(*, parent: tk.Misc | None = None) -> None:
"""UI: Manifest pruefen, Status anzeigen, optional Update starten."""
result = check_update_status()
status = result.get("status")
_log(f"Status={status} detail={result.get('detail') or result.get('message')}")
title = _status_title(result)
message = _status_message(result)
if status == "update_available":
if messagebox.askyesno(title, message, parent=parent):
ok, install_msg = perform_confirmed_update(result, parent=parent)
if ok:
messagebox.showinfo("AZA — Update", install_msg, parent=parent)
else:
messagebox.showwarning("AZA — Update", install_msg, parent=parent)
return
messagebox.showinfo(title, message, parent=parent)
def show_app_info(*, parent: tk.Misc | None = None) -> None:
local = load_local_version()
root = get_project_root()
vf = root / "version.json"
lines = [
"AZA Desktop — Controller / Launcher",
"",
f"Version: {format_version_label(local)}",
f"Kanal: {local.get('channel', 'stable')}",
f"App: {local.get('app', 'AZA Desktop')}",
"",
f"Installationsordner:\n{root}",
"",
f"version.json:\n{vf if vf.is_file() else '(nicht gefunden)'}",
"",
"Updates: api.aza-medwork.ch/downloads/updates/manifest.json",
"Benutzerdaten bleiben in %APPDATA%\\AzA getrennt.",
]
messagebox.showinfo("AZA — Info", "\n".join(lines), parent=parent)
def main() -> int:
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())