438 lines
14 KiB
Python
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)
|