266 lines
8.2 KiB
Python
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())
|