Files
aza/AzA march 2026 - Kopie (27)/desktop_update_check.py
2026-05-20 00:09:28 +02:00

438 lines
14 KiB
Python

"""
AZA Desktop — Phase-1-Updatepruefung (nur Hinweis + Browser-Download).
Keine automatische Installation, kein Beenden der App, keine E-Mail.
Diagnose: client_debug.log (UPDATE_*), ohne Lizenz-/Token-Daten.
"""
from __future__ import annotations
import re
import webbrowser
from datetime import datetime, timezone
from typing import Any
import requests
from aza_config import get_writable_data_dir
from aza_version import APP_CHANNEL, APP_VERSION
# Server-Manifest (mehrere Endpunkte fuer Deploy-Kompatibilitaet).
UPDATE_MANIFEST_URLS = (
"https://api.aza-medwork.ch/download/version.json",
"https://api.aza-medwork.ch/release/version.json",
)
DEFAULT_DOWNLOAD_URL = "https://api.aza-medwork.ch/downloads/aza_desktop_setup.exe"
_BUILD_STAMP_RE = re.compile(r"^\d{8}_\d{6}$")
def _update_debug_log(msg: str) -> None:
try:
import os
path = os.path.join(get_writable_data_dir(), "client_debug.log")
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
with open(path, "a", encoding="utf-8") as f:
f.write(f"[{ts}] {msg}\n")
except Exception:
pass
def _local_build_stamp() -> str:
try:
from _build_info import BUILD_TIMESTAMP
s = str(BUILD_TIMESTAMP).strip()
return s if _BUILD_STAMP_RE.match(s) else ""
except Exception:
return ""
def _parse_semver_tuple(v: str) -> tuple[int, ...]:
"""Semver-artig: numerische Segmente, nicht lexikographisch ('1.10' > '1.2')."""
v = str(v).strip()
if not v:
return tuple()
parts: list[int] = []
for seg in v.split("."):
seg = seg.strip()
m = re.match(r"^(\d+)", seg)
parts.append(int(m.group(1)) if m else 0)
return tuple(parts)
def _semver_pad(
a: tuple[int, ...], b: tuple[int, ...]
) -> tuple[tuple[int, ...], tuple[int, ...]]:
n = max(len(a), len(b))
aa = a + (0,) * (n - len(a))
bb = b + (0,) * (n - len(b))
return aa, bb
def _semver_gt(ver_a: str, ver_b: str) -> bool:
ta, tb = _parse_semver_tuple(ver_a), _parse_semver_tuple(ver_b)
if not ta or not tb:
return False
aa, bb = _semver_pad(ta, tb)
return aa > bb
def _semver_eq(ver_a: str, ver_b: str) -> bool:
ta, tb = _parse_semver_tuple(ver_a), _parse_semver_tuple(ver_b)
if not ta and not tb:
return str(ver_a).strip() == str(ver_b).strip()
aa, bb = _semver_pad(ta, tb)
return aa == bb
def _below_min_required(current: str, min_req: str) -> bool:
mr = (min_req or "").strip()
if not mr:
return False
return _semver_gt(mr, current)
def _build_gt(remote: str, local: str) -> bool:
"""Tie-Breaker bei gleicher Versionsnummer (yyyyMMdd_HHmmss)."""
r, l = (remote or "").strip(), (local or "").strip()
if not r or not l:
return False
if _BUILD_STAMP_RE.match(r) and _BUILD_STAMP_RE.match(l):
return r > l
return r > l
def _normalize_notes(data: dict[str, Any]) -> list[str]:
raw = data.get("notes")
if raw is None:
raw = data.get("release_notes")
if raw is None:
return []
if isinstance(raw, str):
return [raw.strip()] if raw.strip() else []
if isinstance(raw, list):
return [str(x).strip() for x in raw if str(x).strip()]
return []
def _min_required_from_manifest(data: dict[str, Any]) -> str:
for key in ("min_required_version", "minimum_supported_version"):
v = data.get(key)
if v is not None and str(v).strip():
return str(v).strip()
return ""
def _normalize_update_level(raw: Any) -> str:
s = str(raw or "recommended").strip().lower()
if s in ("optional", "recommended", "required"):
return s
return "recommended"
def fetch_remote_manifest() -> tuple[dict[str, Any] | None, str | None]:
"""HTTP + JSON parsen; ungueltige Antworten nicht in die App durchreichen."""
last: str | None = None
for url in UPDATE_MANIFEST_URLS:
try:
r = requests.get(url, timeout=6)
if r.status_code != 200:
last = f"http_status={r.status_code} url={url}"
continue
try:
data = r.json()
except ValueError:
last = f"invalid_json url={url}"
continue
if not isinstance(data, dict):
last = f"invalid_manifest_shape url={url}"
continue
return data, None
except requests.RequestException as e:
last = f"request_exc={type(e).__name__} url={url}"
except Exception as e:
last = f"exc={type(e).__name__} url={url}"
return None, last or "no_manifest"
def _build_update_info(data: dict[str, Any]) -> dict[str, Any] | None:
latest = str(data.get("version") or "").strip()
if not latest or not str(APP_VERSION).strip():
return None
min_req = _min_required_from_manifest(data)
download_url = str(data.get("download_url") or "").strip() or DEFAULT_DOWNLOAD_URL
update_level = _normalize_update_level(data.get("update_level"))
notes = _normalize_notes(data)
remote_build = str(data.get("build") or "").strip()
sha256 = str(data.get("sha256") or "").strip()
local_build = _local_build_stamp()
below_min = _below_min_required(APP_VERSION, min_req)
newer_semver = _semver_gt(latest, APP_VERSION)
same_semver = _semver_eq(latest, APP_VERSION)
build_bump = bool(same_semver and remote_build and local_build and _build_gt(remote_build, local_build))
client_ahead = _semver_gt(APP_VERSION, latest)
if client_ahead and not below_min:
return None
need = bool(below_min or newer_semver or build_bump)
if not need:
return None
return {
"update_available": True,
"latest_version": latest,
"current_version": APP_VERSION,
"local_build": local_build,
"download_url": download_url,
"remote_build": remote_build,
"sha256": sha256,
"update_level": update_level,
"min_required_version": min_req,
"notes": notes,
"below_min_required": below_min,
}
def check_for_updates() -> dict[str, Any] | None:
"""Kompatibler Einstieg: laedt Manifest und wertet aus (ohne UI, ohne Logging)."""
data, err = fetch_remote_manifest()
if err or not data:
return None
if str(data.get("channel") or "stable").strip() != APP_CHANNEL:
return None
return _build_update_info(data)
def prompt_update_if_available() -> None:
"""Start: pruefen, bei Bedarf Dialog; Netzwerkfehler nur Debug-Log."""
urls = ",".join(UPDATE_MANIFEST_URLS)
_update_debug_log(f"UPDATE_CHECK_START mode=startup urls={urls}")
data, err = fetch_remote_manifest()
if err or not data:
_update_debug_log(f"UPDATE_CHECK_FAILED mode=startup reason=fetch detail={err}")
return
rv = str(data.get("version") or "")
rb = str(data.get("build") or "")
rc = str(data.get("channel") or "stable").strip()
_update_debug_log(
f"UPDATE_CHECK_OK mode=startup remote_version={rv} remote_build={rb} remote_channel={rc}"
)
if rc != APP_CHANNEL:
_update_debug_log(
f"UPDATE_NOT_NEEDED mode=startup reason=channel_mismatch local_channel={APP_CHANNEL} "
f"remote_channel={rc}"
)
return
try:
info = _build_update_info(data)
except Exception as e:
_update_debug_log(f"UPDATE_CHECK_FAILED mode=startup reason=evaluate exc={type(e).__name__}")
return
if not info:
_update_debug_log(
f"UPDATE_NOT_NEEDED mode=startup reason=already_current local_ver={APP_VERSION} "
f"local_build={_local_build_stamp()}"
)
return
if not _startup_should_show_dialog(info):
_update_debug_log(
f"UPDATE_NOT_NEEDED mode=startup reason=optional_deferred_startup "
f"level={info.get('update_level')}"
)
return
lvl = info.get("update_level")
bm = 1 if info.get("below_min_required") else 0
_update_debug_log(
f"UPDATE_AVAILABLE mode=startup level={lvl} remote={info.get('latest_version')} "
f"local={APP_VERSION} below_min_required={bm}"
)
try:
_show_update_notification(info, parent=None)
except Exception as e:
_update_debug_log(f"UPDATE_CHECK_FAILED mode=startup reason=ui exc={type(e).__name__}")
def manual_check_for_updates(parent=None) -> None:
"""Manuell: gleiche Logik; Fehler nur Log; kein Popup bei 'aktuell' oder Fehler."""
urls = ",".join(UPDATE_MANIFEST_URLS)
_update_debug_log(f"UPDATE_CHECK_START mode=manual urls={urls}")
data, err = fetch_remote_manifest()
if err or not data:
_update_debug_log(f"UPDATE_CHECK_FAILED mode=manual reason=fetch detail={err}")
return
rv = str(data.get("version") or "")
rb = str(data.get("build") or "")
rc = str(data.get("channel") or "stable").strip()
_update_debug_log(
f"UPDATE_CHECK_OK mode=manual remote_version={rv} remote_build={rb} remote_channel={rc}"
)
if rc != APP_CHANNEL:
_update_debug_log(
f"UPDATE_NOT_NEEDED mode=manual reason=channel_mismatch local_channel={APP_CHANNEL} "
f"remote_channel={rc}"
)
return
try:
info = _build_update_info(data)
except Exception as e:
_update_debug_log(f"UPDATE_CHECK_FAILED mode=manual reason=evaluate exc={type(e).__name__}")
return
if not info:
_update_debug_log(
f"UPDATE_NOT_NEEDED mode=manual reason=already_current local_ver={APP_VERSION} "
f"local_build={_local_build_stamp()}"
)
return
lvl = info.get("update_level")
bm = 1 if info.get("below_min_required") else 0
_update_debug_log(
f"UPDATE_AVAILABLE mode=manual level={lvl} remote={info.get('latest_version')} "
f"local={APP_VERSION} below_min_required={bm}"
)
try:
_show_update_notification(info, parent=parent)
except Exception as e:
_update_debug_log(f"UPDATE_CHECK_FAILED mode=manual reason=ui exc={type(e).__name__}")
def _startup_should_show_dialog(info: dict[str, Any]) -> bool:
if info.get("below_min_required"):
return True
if info.get("update_level") == "required":
return True
if info.get("update_level") == "optional":
return False
return True
def _show_update_notification(info: dict[str, Any], parent) -> None:
import tkinter as tk
from tkinter import ttk, scrolledtext
download_url = info.get("download_url") or DEFAULT_DOWNLOAD_URL
latest = info.get("latest_version", "")
level = info.get("update_level", "recommended")
notes = info.get("notes") or []
mandatory = bool(info.get("below_min_required") or level == "required")
root = parent
owns_root = False
if root is None:
root = tk.Tk()
root.withdraw()
owns_root = True
dlg = tk.Toplevel(root)
dlg.title("AZA — Aktualisierung")
if not owns_root:
dlg.transient(root)
dlg.resizable(True, True)
dlg.minsize(420, 280)
if mandatory and info.get("below_min_required"):
head = (
f"Pflichtupdate: Ihre AZA-Version liegt unter der Server-Mindestversion.\n"
f"Angebotene Version: {latest} (Sie: {APP_VERSION})\n\n"
"Bitte laden Sie den offiziellen Installer herunter und fuehren Sie ihn aus. "
"AZA schliesst sich dafuer nicht automatisch."
)
elif mandatory:
head = (
f"Erforderliches Update: AZA {latest} muss installiert werden.\n"
f"Ihre Version: {APP_VERSION}\n\n"
"Bitte laden Sie den offiziellen Installer ueber die Schaltflaeche herunter."
)
elif level == "optional":
head = (
f"Optionales Update: AZA {latest} ist verfuegbar.\n"
f"Ihre Version: {APP_VERSION}\n\n"
"Sie koennen jetzt den offiziellen Installer im Browser laden oder später fortfahren."
)
else:
head = (
f"Update empfohlen: AZA {latest} ist verfuegbar.\n"
f"Ihre Version: {APP_VERSION}\n\n"
"Ueber die Schaltflaeche oeffnen Sie den offiziellen Installer-Download im Browser."
)
frm = ttk.Frame(dlg, padding=12)
frm.pack(fill="both", expand=True)
ttk.Label(frm, text=head, wraplength=520, justify="left").pack(anchor="w", pady=(0, 8))
if notes:
ttk.Label(frm, text="Aenderungen:", font=("Segoe UI", 9, "bold")).pack(anchor="w")
box = scrolledtext.ScrolledText(frm, height=8, wrap="word", font=("Segoe UI", 9))
box.pack(fill="both", expand=True, pady=(4, 8))
box.insert("1.0", "\n".join(f"{n}" for n in notes))
box.configure(state="disabled")
btn_row = ttk.Frame(frm)
btn_row.pack(fill="x", pady=(8, 0))
def on_download() -> None:
try:
webbrowser.open(download_url)
except Exception:
pass
def on_close() -> None:
dlg.destroy()
if owns_root:
try:
root.destroy()
except Exception:
pass
ttk.Button(btn_row, text="Update herunterladen", command=on_download).pack(side="left", padx=(0, 8))
if not mandatory:
ttk.Button(btn_row, text="Später", command=on_close).pack(side="left")
dlg.protocol("WM_DELETE_WINDOW", on_close)
dlg.update_idletasks()
try:
if not owns_root:
dlg.geometry(
f"+{root.winfo_x() + max(20, (root.winfo_width() - dlg.winfo_reqwidth()) // 2)}"
f"+{root.winfo_y() + max(20, (root.winfo_height() - dlg.winfo_reqheight()) // 2)}"
)
else:
sw = dlg.winfo_screenwidth()
sh = dlg.winfo_screenheight()
dlg.geometry(
f"+{(sw - dlg.winfo_reqwidth()) // 2}+{(sh - dlg.winfo_reqheight()) // 2}"
)
except Exception:
pass
dlg.grab_set()
if owns_root:
dlg.wait_window()
else:
parent.wait_window(dlg)
if __name__ == "__main__":
d, e = fetch_remote_manifest()
print("fetch_err", e, "keys", list(d.keys()) if d else None)