# -*- 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())