""" 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)