581 lines
19 KiB
Python
581 lines
19 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 json
|
|
import os
|
|
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
|
|
from aza_update_core import save_update_pending
|
|
|
|
# Session-Schutz: Pro Prozesslauf darf „Später" die Anzeige nicht sofort wiederholen.
|
|
_update_dialog_shown_this_session: bool = False
|
|
|
|
# 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}$")
|
|
|
|
_OFFICE_UPDATE_HINT_FILE = "office_update_hint.json"
|
|
_OFFICE_UPDATE_REQUEST_FLAG = "office_update_request.flag"
|
|
|
|
|
|
def _office_update_ipc_path(name: str) -> str:
|
|
return os.path.join(get_writable_data_dir(), name)
|
|
|
|
|
|
def sync_office_update_hint(info: dict[str, Any] | None) -> None:
|
|
"""IPC fuer Empfang-Huelle: Office ist alleiniger Update-Owner (kein Installer in Huelle)."""
|
|
hint_path = _office_update_ipc_path(_OFFICE_UPDATE_HINT_FILE)
|
|
try:
|
|
if not info or not info.get("update_available"):
|
|
if os.path.isfile(hint_path):
|
|
os.remove(hint_path)
|
|
return
|
|
payload = {
|
|
"available": True,
|
|
"latest_version": str(info.get("latest_version") or "").strip(),
|
|
"update_level": str(info.get("update_level") or "recommended").strip(),
|
|
}
|
|
with open(hint_path, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, ensure_ascii=False)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def read_office_update_hint() -> dict[str, Any] | None:
|
|
try:
|
|
path = _office_update_ipc_path(_OFFICE_UPDATE_HINT_FILE)
|
|
if not os.path.isfile(path):
|
|
return None
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if isinstance(data, dict) and data.get("available"):
|
|
return data
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def request_office_update_dialog_ipc() -> None:
|
|
"""Huelle -> Office: Flag setzen; Office oeffnet Update-Dialog beim naechsten Poll."""
|
|
try:
|
|
path = _office_update_ipc_path(_OFFICE_UPDATE_REQUEST_FLAG)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write("1")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def consume_office_update_request_flag() -> bool:
|
|
try:
|
|
path = _office_update_ipc_path(_OFFICE_UPDATE_REQUEST_FLAG)
|
|
if not os.path.isfile(path):
|
|
return False
|
|
os.remove(path)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
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 _installed_current_version() -> str:
|
|
"""Kanonische installierte Version aus version.json (gleiche Quelle wie Startpanel/Updater).
|
|
|
|
Fallback auf APP_VERSION (Quellcode), falls keine version.json gefunden wird.
|
|
Bei Abweichung wird nur Pfad + Version geloggt (keine Tokens/Patientendaten),
|
|
damit eine falsche installierte Version diagnostizierbar ist.
|
|
"""
|
|
try:
|
|
from aza_update_core import load_local_version, find_local_version_file
|
|
|
|
info = load_local_version()
|
|
ver = str(info.get("version") or "").strip()
|
|
if ver:
|
|
if not _semver_eq(ver, APP_VERSION):
|
|
try:
|
|
vf = find_local_version_file()
|
|
_update_debug_log(
|
|
f"VERSION_SOURCE installed={ver} code={APP_VERSION} "
|
|
f"source={vf if vf else 'fallback_code'}"
|
|
)
|
|
except Exception:
|
|
pass
|
|
return ver
|
|
except Exception:
|
|
pass
|
|
return str(APP_VERSION).strip()
|
|
|
|
|
|
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()
|
|
# current_version kanonisch aus installierter version.json (Fallback APP_VERSION),
|
|
# damit Office und Startpanel exakt dieselbe installierte Version vergleichen.
|
|
current = _installed_current_version()
|
|
if not latest or not current:
|
|
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(current, min_req)
|
|
newer_semver = _semver_gt(latest, current)
|
|
same_semver = _semver_eq(latest, current)
|
|
# build_bump ist fuer den stable-Kanal deaktiviert: gleiche Version = kein Update.
|
|
# Build-Nummern koennen bei Manifest-Rebuilds hoher sein als der installierte Build
|
|
# obwohl dieselbe Codeversion installiert ist. Das wuerde sonst eine Endlosschleife
|
|
# verursachen (1.3.5 204826 > 1.3.5 151416 → immer Update angeboten).
|
|
build_bump = False
|
|
|
|
client_ahead = _semver_gt(current, 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": current,
|
|
"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:
|
|
sync_office_update_hint(None)
|
|
return None
|
|
if str(data.get("channel") or "stable").strip() != APP_CHANNEL:
|
|
sync_office_update_hint(None)
|
|
return None
|
|
info = _build_update_info(data)
|
|
sync_office_update_hint(info)
|
|
return info
|
|
|
|
|
|
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 _trigger_app_close(parent) -> None:
|
|
"""Schliesst das Hauptfenster sauber, damit _on_close() das Pending-Update aufgreift.
|
|
WICHTIG: _on_close() statt shutdown_app_completely() verwenden, weil _on_close()
|
|
als Einziges den Pending-Update-Check enthaelt und launch_external_installer startet.
|
|
shutdown_app_completely() bypassed _on_close() und wuerde den Updater nie starten.
|
|
"""
|
|
try:
|
|
if hasattr(parent, "_on_close"):
|
|
parent._on_close()
|
|
elif hasattr(parent, "shutdown_app_completely"):
|
|
parent.shutdown_app_completely(reason="update_on_exit")
|
|
else:
|
|
parent.destroy()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
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:
|
|
global _update_dialog_shown_this_session
|
|
if _update_dialog_shown_this_session:
|
|
return False
|
|
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
|
|
|
|
|
|
# Verhindert zwei gleichzeitig offene Update-Dialoge (Startup-Poll + manueller Button).
|
|
_update_dialog_open: bool = False
|
|
|
|
|
|
def _show_update_notification(info: dict[str, Any], parent) -> None:
|
|
"""Office-Update-Dialog.
|
|
|
|
Nutzt das professionelle Fenster aus aza_updater._show_update_dialog_professional
|
|
(EINZIGE Kopie, keine zweite Dialog-Implementierung) und verarbeitet die drei
|
|
Auswahlmoeglichkeiten now / on_exit / later. Owner ist ausschliesslich AzA Office.
|
|
"""
|
|
global _update_dialog_shown_this_session, _update_dialog_open
|
|
if _update_dialog_open:
|
|
return
|
|
_update_dialog_shown_this_session = True
|
|
|
|
latest = str(info.get("latest_version", "") or "")
|
|
current = str(info.get("current_version", "") or "")
|
|
notes = info.get("notes") or []
|
|
level = info.get("update_level", "recommended")
|
|
mandatory = bool(info.get("below_min_required") or level == "required")
|
|
|
|
# Adapter auf das Eingabeformat des professionellen Dialogs.
|
|
result_for_dialog = {
|
|
"local": {"version": current},
|
|
"latest_version": latest,
|
|
"notes": notes,
|
|
"mandatory": mandatory,
|
|
"below_min_supported": bool(info.get("below_min_required")),
|
|
}
|
|
|
|
_update_dialog_open = True
|
|
try:
|
|
from aza_updater import _show_update_dialog_professional
|
|
|
|
choice = _show_update_dialog_professional(result_for_dialog, parent=parent)
|
|
except Exception as exc:
|
|
_update_debug_log(f"UPDATE_DIALOG_PRO_FAILED exc={type(exc).__name__}")
|
|
choice = "later"
|
|
finally:
|
|
_update_dialog_open = False
|
|
|
|
if choice == "later":
|
|
# Kein Pending, kein Installer. Dialog erscheint in dieser Sitzung nicht
|
|
# erneut (Session-Guard); der orange Update-Button bleibt sichtbar.
|
|
_update_debug_log("UPDATE_DIALOG_CHOICE=later")
|
|
return
|
|
|
|
if choice == "on_exit":
|
|
# Genau ein Pending speichern. Office bleibt offen; _on_close() konsumiert
|
|
# den Auftrag einmalig (clear vor launch).
|
|
try:
|
|
save_update_pending(UPDATE_MANIFEST_URLS[0], latest)
|
|
_update_debug_log(f"UPDATE_DIALOG_CHOICE=on_exit version={latest}")
|
|
except Exception as exc:
|
|
_update_debug_log(f"UPDATE_PENDING_SAVE_FAILED exc={type(exc).__name__}")
|
|
return
|
|
|
|
# choice == "now": genau ein Installerstart + kontrollierter Office-Shutdown.
|
|
_update_debug_log(f"UPDATE_DIALOG_CHOICE=now version={latest}")
|
|
ok, msg = False, ""
|
|
try:
|
|
from aza_update_core import clear_update_pending
|
|
from aza_updater import launch_external_installer
|
|
|
|
# Kein doppeltes Pending fuer einen spaeteren zweiten Start.
|
|
clear_update_pending()
|
|
result = {
|
|
"remote": {"_manifest_url": UPDATE_MANIFEST_URLS[0]},
|
|
"latest_version": latest,
|
|
}
|
|
ok, msg = launch_external_installer(result, restart_exe="aza_start_panel.exe")
|
|
except Exception as exc:
|
|
_update_debug_log(f"UPDATE_LAUNCH_EXC exc={type(exc).__name__}")
|
|
ok, msg = False, str(exc)
|
|
|
|
if not ok:
|
|
try:
|
|
import tkinter.messagebox as _mb
|
|
|
|
_mb.showerror(
|
|
"Update",
|
|
f"Der Updater konnte nicht gestartet werden:\n{msg}\n\n"
|
|
"Bitte versuchen Sie es spaeter erneut.",
|
|
parent=parent,
|
|
)
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
# Office kontrolliert schliessen; der Installer beendet die uebrigen
|
|
# AzA-Fenster (CloseApplications / _stop_aza_processes) und ist einziger
|
|
# Restart-Owner ueber [Run] postinstall.
|
|
if parent is not None:
|
|
try:
|
|
parent.after(300, lambda: _close_app_after_update(parent))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def maybe_show_startup_update_dialog(info: dict[str, Any] | None, parent=None) -> None:
|
|
"""Einmaliger Startup-Dialog aus AzA Office (Owner).
|
|
|
|
Verwendet das bereits vom Update-Button-Poll geladene ``info`` (kein zweiter
|
|
Manifest-Fetch), respektiert den Session-Guard und die optional/required-Regeln.
|
|
"""
|
|
if not info:
|
|
return
|
|
if not _startup_should_show_dialog(info):
|
|
return
|
|
_show_update_notification(info, parent=parent)
|
|
|
|
|
|
def _close_app_after_update(parent) -> None:
|
|
"""Schliesst die App nach gestartetem Updater sauber."""
|
|
try:
|
|
if hasattr(parent, "shutdown_app_completely"):
|
|
parent.shutdown_app_completely(reason="update_on_exit")
|
|
else:
|
|
parent.destroy()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
d, e = fetch_remote_manifest()
|
|
print("fetch_err", e, "keys", list(d.keys()) if d else None)
|