This commit is contained in:
2026-05-23 21:31:34 +02:00
parent 51b5ddc6f2
commit 641bb10479
6155 changed files with 3775717 additions and 291 deletions

View File

@@ -0,0 +1,2 @@
Backup vor Updater E2E Local Test
Erstellt: 20260522_181526

View File

@@ -0,0 +1,958 @@
# -*- coding: utf-8 -*-
"""
AzA Einheitliches Startpanel (WebView-Huelle mit MP4-Hintergrund).
Sichtbar: AzA Office | Praxis Chat
Technisch Praxischat = bisheriger MiniChat-Modus (minichat=1), unveraendert.
UI: pywebview + lokalem HTTP (127.0.0.1) fuer MP4/PNG/Logo.
WebView2 blockiert file:// in html= about:blank — daher kein reines as_uri() im Panel.
Fallback nur bei fehlendem pywebview: Tkinter + PNG (kein laufendes Video).
"""
from __future__ import annotations
import html
import mimetypes
import os
import subprocess
import sys
import threading
import tkinter as tk
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from tkinter import messagebox
from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse
try:
from PIL import Image, ImageEnhance, ImageFilter, ImageTk
_HAS_PIL = True
except Exception:
_HAS_PIL = False
_ROOT = Path(__file__).resolve().parent
_W, _H = 940, 625
_CARD_W = 270 # Feinschliff 235 px + 15 %
_MP4 = _ROOT / "assets" / "matternhorn-aza-2.mp4"
_PNG = _ROOT / "assets" / "matternhorn.png"
_LOGO = _ROOT / "logo.png"
_WIN_APP_ID = "ch.aza-medwork.startpanel.v1"
_FALLBACK_BG = "#d4e3ef"
_CARD_BG = "#f5f8fb"
_ACCENT = "#1a5f8a"
_ACCENT_HOVER = "#164f73"
_TEXT = "#1a2a3a"
_SUBTLE = "#5a6d7d"
_FONT_TITLE = ("Segoe UI", 18, "bold")
_FONT_SUB = ("Segoe UI", 10)
_FONT_BTN = ("Segoe UI", 10, "bold")
_FONT_FOOT = ("Segoe UI", 8)
def _file_uri(path: Path) -> str:
if path.is_file():
return path.resolve().as_uri()
return ""
def _short_uri(uri: str, max_len: int = 72) -> str:
if len(uri) <= max_len:
return uri
return uri[: max_len - 1] + ""
def _log_asset_status(*, asset_mode: str = "", start_url: str = "") -> None:
video_exists = _MP4.is_file()
video_size = int(_MP4.stat().st_size) if video_exists else 0
print(f"[AzA Startpanel] VIDEO_EXISTS={video_exists}")
print(f"[AzA Startpanel] VIDEO_SIZE_BYTES={video_size}")
print(f"[AzA Startpanel] VIDEO_URI={_short_uri(_file_uri(_MP4))}")
print(f"[AzA Startpanel] PNG_EXISTS={_PNG.is_file()}")
print(f"[AzA Startpanel] LOGO_EXISTS={_LOGO.is_file()}")
if asset_mode:
print(f"[AzA Startpanel] ASSET_MODE={asset_mode}")
if start_url:
print(f"[AzA Startpanel] START_URL={_short_uri(start_url, 96)}")
def _make_start_panel_handler(root_dir: Path, panel_html_supplier):
"""Liefert nur /start (HTML) und statische Dateien unter Projektroot."""
class Handler(BaseHTTPRequestHandler):
def log_message(self, _format, *_args) -> None:
pass
def do_GET(self) -> None:
path = unquote(urlparse(self.path).path)
if path in ("/start", "/"):
panel_html = panel_html_supplier()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(panel_html)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(panel_html)
return
rel = path.lstrip("/").replace("/", os.sep)
if not rel or ".." in rel.split(os.sep):
self.send_error(404)
return
fp = (root_dir / rel).resolve()
try:
fp.relative_to(root_dir.resolve())
except ValueError:
self.send_error(403)
return
if not fp.is_file():
self.send_error(404)
return
data = fp.read_bytes()
ctype = mimetypes.guess_type(str(fp))[0] or "application/octet-stream"
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
return Handler
class _LocalAssetServer:
"""localhost-only HTTP fuer WebView2-Assets (MP4/PNG/Logo)."""
def __init__(self, root_dir: Path) -> None:
self._root_dir = root_dir
self._panel_html: bytes = b""
self._httpd: ThreadingHTTPServer | None = None
self.base_url = ""
def _panel_html_bytes(self) -> bytes:
if not self._panel_html and self.base_url:
self._panel_html = _build_panel_html(self.base_url).encode("utf-8")
return self._panel_html
def start(self) -> str:
handler = _make_start_panel_handler(self._root_dir, self._panel_html_bytes)
self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
host, port = self._httpd.server_address
self.base_url = f"http://{host}:{port}"
self._panel_html_bytes()
threading.Thread(target=self._httpd.serve_forever, daemon=True).start()
return f"{self.base_url}/start"
def stop(self) -> None:
if self._httpd is not None:
try:
self._httpd.shutdown()
except Exception:
pass
try:
self._httpd.server_close()
except Exception:
pass
self._httpd = None
def _screen_center_xy(width: int, height: int) -> tuple[int, int]:
"""Bildschirmmitte fuer pywebview x/y und Win32-Fallback."""
sw, sh = 1920, 1080
if sys.platform == "win32":
try:
import ctypes
user32 = ctypes.windll.user32
sw = int(user32.GetSystemMetrics(0))
sh = int(user32.GetSystemMetrics(1))
except Exception:
pass
x = max(0, (sw - width) // 2)
y = max(0, (sh - height) // 2)
return x, y
def _center_window(win: tk.Tk, width: int, height: int) -> None:
win.update_idletasks()
x, y = _screen_center_xy(width, height)
win.geometry(f"{width}x{height}+{x}+{y}")
def _win32_center_window(hwnd: int, width: int, height: int) -> bool:
if sys.platform != "win32" or not hwnd:
return False
try:
import ctypes
user32 = ctypes.windll.user32
x, y = _screen_center_xy(width, height)
SWP_NOSIZE = 0x0001
SWP_NOZORDER = 0x0004
user32.SetWindowPos(hwnd, 0, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER)
return True
except Exception:
return False
def _minichat_url() -> str:
"""Technischer URL-Modus minichat=1 (Praxischat)."""
try:
from aza_empfang_webview import _build_default_empfang_chat_shell_url
base = _build_default_empfang_chat_shell_url()
except Exception:
base = (
os.environ.get("AZA_EMPFANG_CHAT_SHELL_URL") or ""
).strip() or (
"https://empfang.aza-medwork.ch/empfang/"
"?empfang_chat_shell=1&shell_source=empfang_chat_shell"
)
try:
p = urlparse(base)
qs = parse_qs(p.query, keep_blank_values=True)
qs["minichat"] = ["1"]
return urlunparse(p._replace(query=urlencode(qs, doseq=True)))
except Exception:
sep = "&" if "?" in base else "?"
return f"{base}{sep}minichat=1"
def _resolve_office_executable() -> Path | None:
if getattr(sys, "frozen", False):
exe_dir = Path(sys.executable).resolve().parent
for name in ("aza_desktop.exe", "AzA.exe", "AzA_Start.exe"):
p = exe_dir / name
if p.is_file():
return p
meipass = getattr(sys, "_MEIPASS", "")
if meipass:
p = Path(meipass) / "aza_desktop.exe"
if p.is_file():
return p
for p in (_ROOT / "aza_desktop.exe", _ROOT / "dist" / "aza_desktop" / "aza_desktop.exe"):
if p.is_file():
return p
return None
def _resolve_empfang_shell_executable() -> Path | None:
if getattr(sys, "frozen", False):
exe_dir = Path(sys.executable).resolve().parent
p = exe_dir / "AZA_EmpfangShell.exe"
if p.is_file():
return p
meipass = getattr(sys, "_MEIPASS", "")
if meipass:
p = Path(meipass) / "AZA_EmpfangShell.exe"
if p.is_file():
return p
for p in (_ROOT / "AZA_EmpfangShell.exe", _ROOT / "dist" / "AZA_EmpfangShell.exe"):
if p.is_file():
return p
return None
def start_office() -> tuple[bool, str]:
exe = _resolve_office_executable()
try:
if exe is not None:
subprocess.Popen(
[str(exe)],
cwd=str(exe.parent),
close_fds=(sys.platform != "win32"),
)
return True, f"Gestartet: {exe.name}"
script = _ROOT / "basis14.py"
if not script.is_file():
return False, "basis14.py und aza_desktop.exe wurden nicht gefunden."
subprocess.Popen(
[sys.executable, str(script)],
cwd=str(_ROOT),
close_fds=(sys.platform != "win32"),
)
return True, "AzA Office wird gestartet."
except Exception as exc:
return False, f"Office konnte nicht gestartet werden: {exc}"
def start_praxischat() -> tuple[bool, str]:
"""Praxischat = bestehender MiniChat-WebView-Starter (minichat=1)."""
url = _minichat_url()
mode_arg = "--shell-mode=empfang_chat_shell"
w, h = "350", "700"
exe = _resolve_empfang_shell_executable()
try:
if exe is not None:
subprocess.Popen(
[str(exe), mode_arg, url, w, h],
cwd=str(exe.parent),
close_fds=(sys.platform != "win32"),
)
return True, f"Praxischat wird gestartet ({exe.name})."
script = _ROOT / "aza_empfang_webview.py"
if not script.is_file():
return (
False,
"aza_empfang_webview.py und AZA_EmpfangShell.exe wurden nicht gefunden.",
)
subprocess.Popen(
[sys.executable, str(script), mode_arg, url, w, h],
cwd=str(_ROOT),
close_fds=(sys.platform != "win32"),
)
return True, "Praxischat wird gestartet."
except Exception as exc:
return False, f"Praxischat konnte nicht gestartet werden: {exc}"
# Alias fuer aeltere HTML-API-Namen
start_minichat = start_praxischat
class StartPanelApi:
"""pywebview JS-API (Chromium/WebView2)."""
def __init__(self) -> None:
self._window = None
def bind_window(self, window) -> None:
self._window = window
def _close_on_success(self, ok: bool) -> None:
if ok and self._window is not None:
try:
self._window.destroy()
except Exception:
pass
def open_office(self) -> dict:
ok, msg = start_office()
self._close_on_success(ok)
return {"ok": ok, "message": msg}
def open_praxischat(self) -> dict:
ok, msg = start_praxischat()
self._close_on_success(ok)
return {"ok": ok, "message": msg}
# Kompatibilitaet aelterer HTML-Handler
launch_office = open_office
launch_minichat = open_praxischat
def _build_panel_html(asset_base: str) -> str:
"""HTML mit http://127.0.0.1:PORT/... Asset-URLs (WebView2-kompatibel)."""
base = asset_base.rstrip("/")
png = html.escape(f"{base}/assets/matternhorn.png", quote=True)
mp4 = ""
if _MP4.is_file():
mp4 = html.escape(f"{base}/assets/matternhorn-aza-2.mp4", quote=True)
favicon = ""
if _LOGO.is_file():
favicon = (
f'<link rel="icon" type="image/png" href="'
f'{html.escape(f"{base}/logo.png", quote=True)}"/>'
)
logo_block = ""
if _LOGO.is_file():
logo_u = html.escape(f"{base}/logo.png", quote=True)
logo_block = (
f'<img class="logo" id="logoImg" src="{logo_u}" alt="" '
f'onerror="this.remove()"/>'
)
video_block = ""
if mp4:
video_block = f"""
<div class="bg-wrap">
<video id="bgVideo" class="bg-video" autoplay muted loop playsinline preload="auto" poster="{png}">
<source src="{mp4}" type="video/mp4"/>
</video>
<img class="bg-fallback" src="{png}" alt=""/>
</div>"""
else:
video_block = f"""
<div class="bg-wrap">
<img class="bg-fallback show-fallback" src="{png}" alt=""/>
</div>"""
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<title>AzA</title>
{favicon}
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
html, body {{
width: 100%; height: 100%; overflow: hidden;
font-family: "Segoe UI", system-ui, sans-serif;
background: #b8c9d8;
}}
.bg-wrap {{
position: fixed; inset: 0; z-index: 0; overflow: hidden;
width: 100vw; height: 100vh;
}}
.bg-video, .bg-fallback {{
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover;
}}
.bg-fallback {{ display: none; }}
.bg-fallback.show-fallback {{ display: block; }}
body.no-video .bg-video {{ display: none; }}
body.no-video .bg-fallback {{ display: block; }}
.fog {{
position: fixed; inset: 0; z-index: 1;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.34) 0%,
rgba(240, 248, 255, 0.58) 50%,
rgba(230, 240, 250, 0.68) 100%
);
pointer-events: none;
}}
.stage {{
position: fixed; inset: 0; z-index: 2;
display: flex; align-items: center; justify-content: center;
padding: 25px;
}}
.card {{
width: min({_CARD_W}px, 88vw);
padding: 18px 18px 15px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.51);
border: 1px solid rgba(255, 255, 255, 0.64);
box-shadow: 0 9px 33px rgba(20, 40, 60, 0.14);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
text-align: center;
}}
.logo {{
width: 43px; height: 43px; object-fit: contain;
margin: 0 auto 7px; display: block;
}}
h1 {{ font-size: 1.38rem; font-weight: 700; color: #1a2a3a; }}
.sub {{ margin: 6px 0 15px; font-size: 0.83rem; color: #5a6d7d; }}
.btn {{
display: block; width: 100%; margin-bottom: 9px;
padding: 12px 15px; border: none; border-radius: 9px;
font-size: 0.86rem; font-weight: 600; color: #fff;
background: #1a5f8a; cursor: pointer;
box-shadow: 0 3px 13px rgba(26, 95, 138, 0.28);
transition: background 0.15s ease, transform 0.1s ease;
}}
.btn:last-of-type {{ margin-bottom: 0; }}
.btn:hover {{ background: #164f73; }}
.btn:active {{ transform: scale(0.98); }}
.btn:disabled {{ opacity: 0.65; cursor: wait; }}
.foot {{ margin-top: 13px; font-size: 0.67rem; color: #6a7d8c; }}
.err {{ display: none; margin-top: 9px; font-size: 0.71rem; color: #b42318; }}
.err.show {{ display: block; }}
</style>
</head>
<body>
{video_block}
<div class="fog"></div>
<div class="stage">
<div class="card">
{logo_block}
<h1>AzA</h1>
<p class="sub">Was möchten Sie öffnen?</p>
<button type="button" class="btn" id="btnOffice">AzA Office</button>
<button type="button" class="btn" id="btnPraxischat">Praxis Chat</button>
<p class="err" id="errBox"></p>
<p class="foot">AzA Medwork</p>
</div>
</div>
<script>
(function () {{
function useFallback(reason) {{
document.body.classList.add("no-video");
if (reason) console.warn("START_PANEL_VIDEO_FALLBACK", reason);
}}
function initVideo() {{
var v = document.getElementById("bgVideo");
if (!v) {{
useFallback("no_video_element");
return;
}}
v.muted = true;
v.playsInline = true;
v.setAttribute("playsinline", "");
v.addEventListener("error", function () {{
useFallback("error code=" + (v.error ? v.error.code : "?"));
}});
v.addEventListener("playing", function () {{
document.body.classList.remove("no-video");
}});
v.addEventListener("canplay", function () {{
document.body.classList.remove("no-video");
v.play().catch(function () {{ useFallback("play_rejected"); }});
}});
setTimeout(function () {{
if (v.error) useFallback("timeout_error");
else if (v.readyState < 2 && v.networkState === 3) useFallback("timeout_no_source");
}}, 15000);
try {{ v.load(); }} catch (e) {{ useFallback("load_failed"); }}
v.play().catch(function () {{}});
}}
if (document.readyState === "loading") {{
document.addEventListener("DOMContentLoaded", initVideo);
}} else {{
initVideo();
}}
function showErr(msg) {{
var el = document.getElementById("errBox");
el.textContent = msg || "";
el.classList.toggle("show", !!msg);
}}
function setBusy(busy) {{
document.getElementById("btnOffice").disabled = busy;
document.getElementById("btnPraxischat").disabled = busy;
}}
async function callApi(method) {{
if (!window.pywebview || !pywebview.api || !pywebview.api[method]) {{
showErr("Start-API nicht verfügbar.");
return;
}}
setBusy(true);
showErr("");
try {{
var r = await pywebview.api[method]();
if (r && r.ok) return;
showErr((r && r.message) ? r.message : "Start fehlgeschlagen.");
}} catch (e) {{
showErr(String(e && e.message ? e.message : e));
}} finally {{
setBusy(false);
}}
}}
document.getElementById("btnOffice").addEventListener("click", function () {{
callApi("open_office");
}});
document.getElementById("btnPraxischat").addEventListener("click", function () {{
callApi("open_praxischat");
}});
}})();
</script>
</body>
</html>"""
def _webview_storage_dir() -> str:
appdata = os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") or ""
base = Path(appdata) / "AzA" / "StartPanelWebView" if appdata else _ROOT / ".aza_start_panel_webview"
try:
base.mkdir(parents=True, exist_ok=True)
except OSError:
pass
return str(base)
def _has_pywebview() -> bool:
try:
import webview # noqa: F401
return True
except ImportError:
return False
def _should_use_webview() -> bool:
if os.environ.get("AZA_START_PANEL_FORCE_TK", "").strip().lower() in (
"1",
"true",
"yes",
):
return False
return _has_pywebview()
def _icon_path() -> str | None:
return _resolve_native_icon_path()
def _ensure_win32_app_user_model_id() -> None:
"""Eigener Taskleisten-Eintrag statt python.exe (muss vor dem ersten Fenster)."""
if sys.platform != "win32":
return
try:
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(_WIN_APP_ID)
except Exception:
pass
def _resolve_native_icon_path() -> str | None:
"""logo.ico bevorzugt; sonst logo.png -> temporaeres .ico (nur fuer Fenster-Icon)."""
ico = _ROOT / "logo.ico"
if ico.is_file():
return str(ico.resolve())
png = _ROOT / "logo.png"
if png.is_file() and _HAS_PIL:
cache = _ROOT / ".aza_start_panel_icon_cache.ico"
try:
if (not cache.is_file()) or cache.stat().st_mtime < png.stat().st_mtime:
img = Image.open(png)
img.save(
cache,
format="ICO",
sizes=[(16, 16), (32, 32), (48, 48)],
)
return str(cache.resolve())
except Exception:
pass
return None
def _webview_hwnd(window) -> int | None:
native = getattr(window, "native", None)
if native is None:
return None
try:
handle = getattr(native, "Handle", None)
if handle is not None:
return int(handle.ToInt32())
except Exception:
pass
return None
def _win32_find_window_hwnd(title: str = "AzA") -> int | None:
if sys.platform != "win32":
return None
import ctypes
user32 = ctypes.windll.user32
found: list[int] = []
def _enum(hwnd, _lparam) -> bool:
if not user32.IsWindowVisible(hwnd):
return True
buf = ctypes.create_unicode_buffer(256)
if user32.GetWindowTextW(hwnd, buf, 256) and buf.value == title:
found.append(int(hwnd))
return False
return True
WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
user32.EnumWindows(WNDENUMPROC(_enum), 0)
return found[0] if found else None
def _win32_set_window_icon(hwnd: int, icon_path: str) -> bool:
"""WM_SETICON + Klassen-Icon fuer Titelzeile und Taskleiste."""
if sys.platform != "win32":
return False
import ctypes
user32 = ctypes.windll.user32
path = os.path.abspath(icon_path)
if not os.path.isfile(path):
return False
WM_SETICON = 0x0080
ICON_SMALL = 0
ICON_BIG = 1
IMAGE_ICON = 1
LR_LOADFROMFILE = 0x00000010
GCLP_HICON = -14
GCLP_HICONSM = -34
set_class = getattr(user32, "SetClassLongPtrW", None) or user32.SetClassLongW
ok = False
h_default = user32.LoadImageW(None, path, IMAGE_ICON, 0, 0, LR_LOADFROMFILE)
h_small = user32.LoadImageW(None, path, IMAGE_ICON, 16, 16, LR_LOADFROMFILE)
h_big = user32.LoadImageW(None, path, IMAGE_ICON, 32, 32, LR_LOADFROMFILE) or h_default
if h_small:
user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, h_small)
set_class(hwnd, GCLP_HICONSM, h_small)
ok = True
if h_big:
user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, h_big)
set_class(hwnd, GCLP_HICON, h_big)
ok = True
return ok
def _apply_native_window_icon(window) -> bool:
path = _resolve_native_icon_path()
if not path:
print("[AzA Startpanel] WINDOW_ICON=missing")
return False
hwnd = _webview_hwnd(window) or _win32_find_window_hwnd("AzA")
applied = bool(hwnd and _win32_set_window_icon(hwnd, path))
print(
f"[AzA Startpanel] WINDOW_ICON={'ok' if applied else 'pending'} "
f"file={Path(path).name} hwnd={hwnd or 0}"
)
return applied
def _schedule_native_window_icon(window) -> None:
for delay in (0.15, 0.45, 1.0, 2.5):
threading.Timer(delay, lambda: _apply_native_window_icon(window)).start()
def _bind_native_icon_on_loaded(window) -> None:
def _on_loaded() -> None:
_apply_native_window_icon(window)
try:
window.events.loaded += _on_loaded
window.events.shown += _on_loaded
except Exception:
pass
_schedule_native_window_icon(window)
def _bind_window_center_on_shown(window) -> None:
def _center() -> None:
hwnd = _webview_hwnd(window) or _win32_find_window_hwnd("AzA")
if hwnd:
_win32_center_window(hwnd, _W, _H)
try:
window.events.shown += _center
except Exception:
pass
for delay in (0.05, 0.25, 0.6):
threading.Timer(delay, _center).start()
def _prime_pywebview_icon(icon_path: str | None) -> None:
"""Icon vor create_window in pywebview-State (winforms liest _state['icon'])."""
if not icon_path:
return
try:
import webview
webview._state["icon"] = os.path.abspath(icon_path)
except Exception:
pass
def run_webview_panel() -> int:
import webview
_ensure_win32_app_user_model_id()
icon_path = _icon_path()
_prime_pywebview_icon(icon_path)
_log_asset_status()
if not _MP4.is_file():
print("[AzA Startpanel] START_PANEL_VIDEO_FALLBACK reason=mp4_missing")
server = _LocalAssetServer(_ROOT)
start_url = server.start()
_log_asset_status(asset_mode="http_localhost", start_url=start_url)
api = StartPanelApi()
win_x, win_y = _screen_center_xy(_W, _H)
try:
win = webview.create_window(
"AzA",
url=start_url,
width=_W,
height=_H,
x=win_x,
y=win_y,
resizable=False,
js_api=api,
background_color="#b8c9d8",
)
api.bind_window(win)
_bind_native_icon_on_loaded(win)
_bind_window_center_on_shown(win)
try:
from aza_updater import run_startup_update_check_in_background
run_startup_update_check_in_background(delay_seconds=1.5)
except Exception as exc:
print(f"[AzA Startpanel] UPDATE_CHECK init skipped: {exc}")
webview.start(
private_mode=False,
storage_path=_webview_storage_dir(),
icon=icon_path,
)
finally:
server.stop()
return 0
def _load_logo_photo(master: tk.Misc, size: int = 44) -> tk.PhotoImage | None:
if not _LOGO.is_file():
return None
try:
if _HAS_PIL:
img = Image.open(_LOGO).convert("RGBA")
img = img.resize((size, size), Image.Resampling.LANCZOS)
return ImageTk.PhotoImage(img, master=master)
return tk.PhotoImage(file=str(_LOGO), master=master)
except Exception:
return None
def _build_background_photo(master: tk.Misc) -> tk.PhotoImage | None:
if not _PNG.is_file() or not _HAS_PIL:
return None
try:
img = Image.open(_PNG).convert("RGB")
img = ImageEnhance.Brightness(img).enhance(1.08)
img = img.resize((_W, _H), Image.Resampling.LANCZOS)
img = img.filter(ImageFilter.GaussianBlur(radius=1.6))
fog = Image.new("RGBA", (_W, _H), (245, 248, 252, 140))
img = Image.alpha_composite(img.convert("RGBA"), fog).convert("RGB")
return ImageTk.PhotoImage(img, master=master)
except Exception:
return None
class AzAStartPanelTk:
"""Nur wenn pywebview fehlt: statisches PNG, kein MP4."""
def __init__(self) -> None:
self.root = tk.Tk()
self.root.title("AzA")
self.root.resizable(False, False)
self.root.configure(bg=_FALLBACK_BG)
_center_window(self.root, _W, _H)
self._bg_photo: tk.PhotoImage | None = None
self._logo_photo: tk.PhotoImage | None = None
self.canvas = tk.Canvas(
self.root, width=_W, height=_H, highlightthickness=0, bd=0
)
self.canvas.pack(fill="both", expand=True)
self._bg_photo = _build_background_photo(self.root)
if self._bg_photo is not None:
self.canvas.create_image(0, 0, anchor="nw", image=self._bg_photo)
else:
self.canvas.configure(bg=_FALLBACK_BG)
card = tk.Frame(
self.canvas,
bg=_CARD_BG,
highlightbackground="#c8d6e4",
highlightthickness=1,
)
self.canvas.create_window(_W // 2, _H // 2, window=card, width=_CARD_W)
header = tk.Frame(card, bg=_CARD_BG)
header.pack(pady=(18, 6))
self._logo_photo = _load_logo_photo(card, 40)
if self._logo_photo is not None:
tk.Label(header, image=self._logo_photo, bg=_CARD_BG).pack()
tk.Label(header, text="AzA", font=_FONT_TITLE, fg=_TEXT, bg=_CARD_BG).pack(
pady=(8, 2)
)
tk.Label(
header,
text="Was möchten Sie öffnen?",
font=_FONT_SUB,
fg=_SUBTLE,
bg=_CARD_BG,
).pack(pady=(0, 13))
btn_frame = tk.Frame(card, bg=_CARD_BG)
btn_frame.pack(padx=21, pady=(0, 6))
self._mk_btn(btn_frame, "AzA Office", self._on_office).pack(
fill="x", pady=(0, 9)
)
self._mk_btn(btn_frame, "Praxis Chat", self._on_praxischat).pack(fill="x")
tk.Label(
card, text="AzA Medwork", font=_FONT_FOOT, fg=_SUBTLE, bg=_CARD_BG
).pack(pady=(15, 15))
ico = _icon_path()
if ico and sys.platform == "win32":
try:
self.root.iconbitmap(ico)
except Exception:
pass
if not _has_pywebview():
tk.Label(
card,
text="Hinweis: Video-Hintergrund benötigt pywebview.",
font=("Segoe UI", 8),
fg=_SUBTLE,
bg=_CARD_BG,
wraplength=_CARD_W - 24,
).pack(pady=(0, 8))
try:
from aza_updater import run_startup_update_check_in_background
self.root.after(1500, run_startup_update_check_in_background)
except Exception as exc:
print(f"[AzA Startpanel] UPDATE_CHECK init skipped: {exc}")
def _mk_btn(self, parent: tk.Misc, text: str, cmd) -> tk.Button:
btn = tk.Button(
parent,
text=text,
font=_FONT_BTN,
fg="#ffffff",
bg=_ACCENT,
activebackground=_ACCENT_HOVER,
activeforeground="#ffffff",
relief="flat",
bd=0,
padx=16,
pady=9,
cursor="hand2",
command=cmd,
)
btn.bind("<Enter>", lambda _e: btn.configure(bg=_ACCENT_HOVER))
btn.bind("<Leave>", lambda _e: btn.configure(bg=_ACCENT))
return btn
def _on_office(self) -> None:
ok, msg = start_office()
if not ok:
messagebox.showerror("AzA Office", msg, parent=self.root)
else:
self.root.destroy()
def _on_praxischat(self) -> None:
ok, msg = start_praxischat()
if not ok:
messagebox.showerror("Praxischat", msg, parent=self.root)
else:
self.root.destroy()
def run(self) -> None:
self.root.mainloop()
def main() -> int:
_ensure_win32_app_user_model_id()
if _should_use_webview():
try:
return run_webview_panel()
except Exception as exc:
print(f"[AzA Startpanel] WebView-Fehler: {exc}", file=sys.stderr)
try:
root = tk.Tk()
root.withdraw()
messagebox.showerror(
"AzA Startpanel",
"WebView-Huelle konnte nicht starten.\n"
f"{exc}\n\nEs wird das statische Fallback geöffnet (ohne Video).",
parent=root,
)
root.destroy()
except Exception:
pass
AzAStartPanelTk().run()
return 0
if __name__ == "__main__":
_ensure_win32_app_user_model_id()
raise SystemExit(main())

View File

@@ -0,0 +1,686 @@
# -*- coding: utf-8 -*-
"""
AZA Desktop — gemeinsame Versions- und Update-Logik (Controller, Updater, Office).
Phase 1: Manifest lesen, Version vergleichen, SHA256, Backup/Rollback vorbereiten.
Kein stilles Auto-Update, keine Aenderung von Benutzerdaten in %%APPDATA%%\\AzA.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import sys
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
try:
import requests
except ImportError: # pragma: no cover
requests = None # type: ignore
# Neues Update-Manifest (Ziel) + bestehende Endpunkte (Kompatibilitaet).
UPDATE_CHANNEL_MANIFEST_URL = "https://api.aza-medwork.ch/downloads/updates/manifest.json"
UPDATE_MANIFEST_URLS = (
UPDATE_CHANNEL_MANIFEST_URL,
"https://api.aza-medwork.ch/download/version.json",
"https://api.aza-medwork.ch/release/version.json",
)
LOCAL_TEST_MANIFEST_NAME = "test_update_manifest.json"
_BUILD_STAMP_RE = re.compile(r"^\d{8}_\d{6}$")
_VERSION_JSON_NAME = "version.json"
_test_install_dir_override: Path | None = None
def configure_test_install_dir(path: str | Path | None) -> None:
"""Isolierter Test-Installationsordner — nie den Projektroot ueberschreiben."""
global _test_install_dir_override
if path is None or not str(path).strip():
_test_install_dir_override = None
return
_test_install_dir_override = Path(path).resolve()
def get_test_install_dir() -> Path | None:
if _test_install_dir_override is not None:
return _test_install_dir_override
env = (os.environ.get("AZA_UPDATE_TEST_INSTALL_DIR") or "").strip()
if env:
return Path(env).resolve()
return None
def get_install_dir() -> Path:
test_dir = get_test_install_dir()
if test_dir is not None:
return test_dir
return get_project_root()
def get_project_root() -> Path:
"""Installations- bzw. Projektroot (EXE-Verzeichnis oder Skriptordner)."""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
def get_user_data_root() -> Path:
"""Benutzerdaten — darf bei Updates nie geloescht werden."""
appdata = os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") or ""
if appdata:
return Path(appdata) / "AzA"
return Path.home() / "AppData" / "Roaming" / "AzA"
def get_update_backup_root() -> Path:
"""Rollback-Speicher: bevorzugt ProgramData, sonst APPDATA\\AzA."""
program_data = os.environ.get("PROGRAMDATA") or ""
if program_data:
base = Path(program_data) / "AzA" / "update_backups"
else:
base = get_user_data_root() / "update_backups"
try:
base.mkdir(parents=True, exist_ok=True)
except OSError:
pass
return base
def find_local_version_file() -> Path | None:
root = get_project_root()
direct = root / _VERSION_JSON_NAME
if direct.is_file():
return direct
release = root / "release" / _VERSION_JSON_NAME
if release.is_file():
return release
return None
def _parse_semver_tuple(v: str) -> tuple[int, ...]:
v = str(v or "").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))
return a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
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 build_gt(remote: str, local: str) -> bool:
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 _fallback_version_from_code() -> dict[str, Any]:
version = "0.0.0"
channel = "stable"
build = ""
try:
from aza_version import APP_VERSION, APP_CHANNEL
version = str(APP_VERSION).strip() or version
channel = str(APP_CHANNEL).strip() or channel
except Exception:
pass
if not build:
try:
from _build_info import BUILD_TIMESTAMP
build = str(BUILD_TIMESTAMP or "").strip()
except Exception:
pass
return {
"version": version,
"build": build,
"channel": channel,
"app": "AZA Desktop",
}
def load_local_version() -> dict[str, Any]:
"""Liest version.json; Fallback aza_version.py + _build_info.py."""
vf = find_local_version_file()
if vf is not None:
try:
data = json.loads(vf.read_text(encoding="utf-8"))
if isinstance(data, dict):
out = _fallback_version_from_code()
out.update({k: v for k, v in data.items() if v is not None})
return out
except Exception:
pass
return _fallback_version_from_code()
def save_local_version(data: dict[str, Any]) -> Path:
"""Schreibt version.json neben der Installation / im Projektroot."""
root = get_project_root()
target = root / _VERSION_JSON_NAME
payload = dict(load_local_version())
payload.update(data)
target.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return target
def format_version_label(data: dict[str, Any] | None = None) -> str:
info = data or load_local_version()
ver = str(info.get("version") or "?").strip()
build = str(info.get("build") or "").strip()
if build:
return f"v{ver} · {build}"
return f"v{ver}"
def _normalize_notes(data: dict[str, Any]) -> list[str]:
raw = data.get("notes_de")
if raw is None:
raw = data.get("notes")
if raw is None:
raw = data.get("release_notes")
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 _remote_version_fields(data: dict[str, Any]) -> tuple[str, str, str]:
version = str(
data.get("latest_version") or data.get("version") or ""
).strip()
build = str(data.get("latest_build") or data.get("build") or "").strip()
channel = str(data.get("channel") or "stable").strip()
return version, build, channel
def _min_required(data: dict[str, Any]) -> str:
for key in ("min_supported_version", "minimum_supported_version", "min_required_version"):
v = data.get(key)
if v is not None and str(v).strip():
return str(v).strip()
return ""
def _log_internal(msg: str) -> None:
print(f"[AZA Update] {msg}")
def resolve_startup_manifest_sources() -> list[str]:
"""
Quellen fuer die Start-Updatepruefung (kein Legacy-Installer-Manifest).
Prioritaet:
1) AZA_UPDATE_MANIFEST_URL (URL oder lokaler Pfad)
2) test_update_manifest.json bei AZA_UPDATE_TEST_MANIFEST=1
3) oeffentlicher Update-Kanal /downloads/updates/manifest.json
"""
sources: list[str] = []
env_url = (os.environ.get("AZA_UPDATE_MANIFEST_URL") or "").strip()
if env_url:
sources.append(env_url)
use_test = (os.environ.get("AZA_UPDATE_TEST_MANIFEST") or "").strip().lower() in (
"1",
"true",
"yes",
)
test_path = get_project_root() / LOCAL_TEST_MANIFEST_NAME
if use_test and test_path.is_file():
sources.append(str(test_path.resolve()))
if UPDATE_CHANNEL_MANIFEST_URL not in sources:
sources.append(UPDATE_CHANNEL_MANIFEST_URL)
return sources
def _load_manifest_json_text(text: str, *, source: str) -> dict[str, Any] | None:
try:
data = json.loads(text)
except ValueError:
_log_internal(f"invalid_json source={source}")
return None
if not isinstance(data, dict):
_log_internal(f"invalid_manifest_shape source={source}")
return None
data["_manifest_url"] = source
return data
def load_manifest_from_source(source: str) -> tuple[dict[str, Any] | None, str | None]:
"""Laedt Manifest von HTTP(S)-URL oder lokaler Datei."""
src = (source or "").strip()
if not src:
return None, "empty_source"
if src.lower().startswith("file://"):
src = src[7:]
path = Path(src)
if path.is_file() or (not src.lower().startswith("http") and path.exists()):
try:
text = path.read_text(encoding="utf-8-sig")
data = _load_manifest_json_text(text, source=str(path.resolve()))
return (data, None) if data else (None, "invalid_json")
except OSError as exc:
return None, f"file_read={type(exc).__name__}"
if requests is None:
return None, "requests_not_installed"
try:
r = requests.get(src, timeout=8.0)
if r.status_code == 404:
return None, f"http_status=404 url={src}"
if r.status_code != 200:
return None, f"http_status={r.status_code} url={src}"
text = r.content.decode("utf-8-sig")
data = _load_manifest_json_text(text, source=src)
return (data, None) if data else (None, "invalid_json")
except Exception as exc:
return None, f"exc={type(exc).__name__} url={src}"
def fetch_startup_manifest(
*,
sources: list[str] | None = None,
) -> tuple[dict[str, Any] | None, str | None]:
"""Nur Update-Kanal — kein Fallback auf Installer-version.json."""
last: str | None = None
for source in sources or resolve_startup_manifest_sources():
data, err = load_manifest_from_source(source)
if data:
return data, None
last = err
if err and "404" in err:
_log_internal(f"Update manifest not available ({err})")
return None, last or "no_manifest"
def _update_files_from_manifest(data: dict[str, Any]) -> list[dict[str, Any]]:
files = data.get("files")
if isinstance(files, list) and files:
out: list[dict[str, Any]] = []
for item in files:
if isinstance(item, dict) and item.get("url"):
out.append(dict(item))
return out
download_url = str(data.get("download_url") or "").strip()
if download_url:
return [
{
"name": Path(download_url).name or "aza_desktop_setup.exe",
"url": download_url,
"sha256": str(data.get("sha256") or "").strip(),
"size_bytes": data.get("size_bytes"),
}
]
return []
def fetch_remote_manifest(
*,
timeout: float = 8.0,
urls: tuple[str, ...] | None = None,
) -> tuple[dict[str, Any] | None, str | None]:
"""Laedt Manifest (inkl. Legacy-Installer-Endpunkte)."""
last: str | None = None
for url in urls or UPDATE_MANIFEST_URLS:
data, err = load_manifest_from_source(url)
if data:
return data, None
last = err
return None, last or "no_manifest"
def check_update_for_startup() -> dict[str, Any]:
"""
Start-Updatepruefung fuer das schoene Startpanel.
status:
- manifest_unavailable (404/kein Netz — still fuer Benutzer)
- current
- update_available
- channel_mismatch
- error
"""
local = load_local_version()
remote, err = fetch_startup_manifest()
if err or not remote:
silent = bool(
err
and (
"404" in err
or err in ("no_manifest", "requests_not_installed")
or err.startswith("exc=")
)
)
return {
"status": "manifest_unavailable" if silent else "error",
"message": "Update manifest not available" if silent else "Manifest nicht erreichbar.",
"detail": err,
"local": local,
}
result = evaluate_update(local, remote)
result["detail"] = err
return result
def validate_update_install_ready(result: dict[str, Any]) -> tuple[bool, str]:
"""Prueft alle Voraussetzungen vor einer Installation."""
if result.get("status") != "update_available":
return False, "Kein Update verfuegbar."
files = result.get("files") or []
if not files:
return False, "Kein Update-Paket im Manifest."
file_info = files[0]
url = str(file_info.get("url") or "").strip()
if not url:
return False, "Download-URL fehlt."
sha = str(file_info.get("sha256") or "").strip()
if not sha:
return False, "SHA256 fehlt im Manifest."
latest = str(result.get("latest_version") or "").strip()
if not latest:
return False, "Versionsangabe fehlt."
name = str(file_info.get("name") or Path(url).name or "").strip()
if name.lower().endswith(".zip"):
return True, "ok"
if name.lower().endswith(".exe"):
return True, "ok"
return False, "Unbekanntes Update-Format."
def evaluate_update(
local: dict[str, Any] | None = None,
remote: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Vergleicht lokal vs. remote.
status:
- current
- update_available
- below_min_supported
- channel_mismatch
- error
"""
loc = local or load_local_version()
if remote is None:
return {
"status": "error",
"message": "Kein Remote-Manifest",
"local": loc,
}
local_ver = str(loc.get("version") or "").strip()
local_build = str(loc.get("build") or "").strip()
local_channel = str(loc.get("channel") or "stable").strip()
remote_ver, remote_build, remote_channel = _remote_version_fields(remote)
if remote_channel != local_channel:
return {
"status": "channel_mismatch",
"message": f"Kanal lokal={local_channel}, remote={remote_channel}",
"local": loc,
"remote": remote,
}
min_req = _min_required(remote)
below_min = bool(min_req and semver_gt(min_req, local_ver))
newer_semver = semver_gt(remote_ver, local_ver)
same_semver = semver_eq(remote_ver, local_ver)
build_bump = bool(
same_semver and remote_build and local_build and build_gt(remote_build, local_build)
)
client_ahead = semver_gt(local_ver, remote_ver)
if client_ahead and not below_min:
return {
"status": "current",
"message": "Lokal ist aktuell oder neuer.",
"local": loc,
"remote": remote,
}
if below_min or newer_semver or build_bump:
files = _update_files_from_manifest(remote)
return {
"status": "update_available",
"message": "Update verfuegbar.",
"local": loc,
"remote": remote,
"latest_version": remote_ver,
"latest_build": remote_build,
"below_min_supported": below_min,
"mandatory": bool(remote.get("mandatory")) or below_min,
"notes": _normalize_notes(remote),
"files": files,
"manifest_url": remote.get("_manifest_url"),
}
return {
"status": "current",
"message": "Kein Update noetig.",
"local": loc,
"remote": remote,
}
def compute_sha256(path: Path, *, chunk_size: int = 1024 * 1024) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
h.update(chunk)
return h.hexdigest().upper()
def verify_sha256(path: Path, expected: str) -> bool:
exp = str(expected or "").strip().upper()
if not exp or not path.is_file():
return False
return compute_sha256(path) == exp
def download_file(
url: str,
dest: Path,
*,
timeout: float = 120.0,
progress: Callable[[int, int | None], None] | None = None,
) -> tuple[bool, str]:
"""Laedt eine Datei herunter. Keine Installation."""
if requests is None:
return False, "requests_not_installed"
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".part")
try:
with requests.get(url, stream=True, timeout=timeout) as r:
if r.status_code != 200:
return False, f"http_status={r.status_code}"
total = int(r.headers.get("Content-Length") or 0) or None
done = 0
with tmp.open("wb") as f:
for chunk in r.iter_content(chunk_size=1024 * 256):
if not chunk:
continue
f.write(chunk)
done += len(chunk)
if progress:
progress(done, total)
tmp.replace(dest)
return True, "ok"
except Exception as exc:
try:
if tmp.is_file():
tmp.unlink()
except OSError:
pass
return False, f"{type(exc).__name__}: {exc}"
def create_pre_update_backup(
install_dir: Path | None = None,
*,
extra_names: tuple[str, ...] = (
"aza_desktop.exe",
"aza_controller.exe",
"aza_office.exe",
"aza_praxis_chat.exe",
"aza_updater.exe",
"AZA_EmpfangShell.exe",
"version.json",
"BUILD_INFO.txt",
),
) -> Path:
"""
Sichert EXEs und version.json vor einem Update.
Benutzerdaten in APPDATA werden nicht angetastet.
"""
src = install_dir or get_project_root()
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir = get_update_backup_root() / stamp
backup_dir.mkdir(parents=True, exist_ok=True)
copied: list[str] = []
for name in extra_names:
p = src / name
if p.is_file():
shutil.copy2(p, backup_dir / name)
copied.append(name)
runtime = src / "runtime"
if runtime.is_dir():
shutil.copytree(runtime, backup_dir / "runtime", dirs_exist_ok=True)
copied.append("runtime/")
meta = {
"created_at": datetime.now(timezone.utc).isoformat(),
"install_dir": str(src),
"copied": copied,
}
(backup_dir / "backup_meta.json").write_text(
json.dumps(meta, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return backup_dir
def rollback_from_backup(
backup_dir: Path,
install_dir: Path | None = None,
) -> tuple[bool, str]:
"""Stellt gesicherte Programmdateien wieder her."""
if not backup_dir.is_dir():
return False, "Backup-Ordner nicht gefunden."
dst = install_dir or get_project_root()
restored: list[str] = []
for item in backup_dir.iterdir():
if item.name == "backup_meta.json":
continue
target = dst / item.name
try:
if item.is_dir():
if target.exists():
shutil.rmtree(target)
shutil.copytree(item, target)
elif item.is_file():
shutil.copy2(item, target)
restored.append(item.name)
except Exception as exc:
return False, f"Rollback fehlgeschlagen bei {item.name}: {exc}"
return True, f"Wiederhergestellt: {', '.join(restored)}"
def list_available_backups() -> list[Path]:
root = get_update_backup_root()
if not root.is_dir():
return []
dirs = [p for p in root.iterdir() if p.is_dir()]
return sorted(dirs, reverse=True)
def extract_update_zip(zip_path: Path, target_dir: Path) -> tuple[bool, str]:
"""Entpackt ein Update-ZIP in das Installationsverzeichnis (ohne APPDATA)."""
if not zip_path.is_file():
return False, "ZIP nicht gefunden."
try:
with zipfile.ZipFile(zip_path, "r") as zf:
for member in zf.namelist():
if member.startswith("/") or ".." in Path(member).parts:
return False, f"Unsicherer ZIP-Eintrag: {member}"
zf.extractall(target_dir)
return True, "ok"
except Exception as exc:
return False, f"{type(exc).__name__}: {exc}"
def download_update_package(
file_info: dict[str, Any],
*,
work_dir: Path | None = None,
progress: Callable[[int, int | None], None] | None = None,
) -> tuple[Path | None, str]:
"""Laedt ein Update-Paket herunter und prueft SHA256."""
url = str(file_info.get("url") or "").strip()
if not url:
return None, "Keine Download-URL."
name = str(file_info.get("name") or Path(url).name or "aza_update.zip")
base = work_dir or Path(tempfile.gettempdir()) / "aza_updates"
base.mkdir(parents=True, exist_ok=True)
dest = base / name
ok, msg = download_file(url, dest, progress=progress)
if not ok:
return None, msg
expected = str(file_info.get("sha256") or "").strip()
if not expected:
return None, "SHA256 fehlt — Download abgebrochen."
if not verify_sha256(dest, expected):
try:
dest.unlink()
except OSError:
pass
return None, "SHA256 stimmt nicht ueberein — Download verworfen."
return dest, "ok"

View File

@@ -0,0 +1,341 @@
# -*- coding: utf-8 -*-
"""
AZA Desktop Updater — Startprompt + sichere Installation.
Beim Startpanel-Start: freundliche Nachfrage bei neuer Version.
Kein sichtbarer Update-Button, kein stilles Auto-Update.
"""
from __future__ import annotations
import subprocess
import sys
import threading
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk
from typing import Any
from aza_update_core import (
check_update_for_startup,
create_pre_update_backup,
download_update_package,
evaluate_update,
extract_update_zip,
fetch_remote_manifest,
format_version_label,
get_project_root,
load_local_version,
rollback_from_backup,
save_local_version,
validate_update_install_ready,
)
def _log(msg: str) -> None:
print(f"[AZA Updater] {msg}")
def check_update_status(*, startup: bool = False) -> dict[str, Any]:
"""Manifest pruefen — startup=True nutzt nur Update-Kanal (kein Legacy-Fallback)."""
if startup:
return check_update_for_startup()
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["detail"] = err
return result
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.\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"Installation fehlgeschlagen.\nRollback durchgefuehrt."
else:
return False, "Unbekanntes Update-Format."
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 write failed: {exc}")
return True, "Das Update wurde installiert.\nBitte starten Sie AZA neu."
def perform_confirmed_update(
result: dict[str, Any],
*,
parent: tk.Misc | None = None,
) -> tuple[bool, str]:
"""Download + SHA256 + Backup + Installation — nur bei gueltigem Manifest."""
ready, reason = validate_update_install_ready(result)
if not ready:
return False, (
"Das Update ist noch nicht vollstaendig verfuegbar.\n"
"Bitte versuchen Sie es spaeter erneut."
)
files = result.get("files") or []
file_info = files[0]
_log("Download starten")
package, err = download_update_package(file_info)
if not package:
_log(f"Download fehlgeschlagen: {err}")
return False, (
"Das Update konnte nicht heruntergeladen werden.\n"
"Bitte versuchen Sie es spaeter erneut."
)
_stop_aza_processes()
return _apply_downloaded_update(package, result)
def _show_friendly_update_dialog(
result: dict[str, Any],
*,
parent: tk.Misc | None = None,
) -> bool:
"""Freundlicher Dialog — True = Jetzt aktualisieren."""
owns = False
root = parent
if root is None:
root = tk.Tk()
root.withdraw()
owns = True
choice = {"install": False}
dlg = tk.Toplevel(root)
dlg.title("Update verfügbar")
dlg.resizable(False, False)
dlg.transient(root)
dlg.grab_set()
frm = ttk.Frame(dlg, padding=18)
frm.pack(fill="both", expand=True)
ttk.Label(
frm,
text="Eine neue AZA-Version ist verfügbar.",
font=("Segoe UI", 11, "bold"),
wraplength=360,
).pack(anchor="w", pady=(0, 8))
ttk.Label(
frm,
text="Möchten Sie das Update jetzt installieren?",
font=("Segoe UI", 10),
wraplength=360,
).pack(anchor="w", pady=(0, 6))
ttk.Label(
frm,
text="Ihre aktuellen Einstellungen und Praxisdaten bleiben erhalten.",
font=("Segoe UI", 9),
foreground="#5a6d7d",
wraplength=360,
).pack(anchor="w", pady=(0, 14))
btn_row = ttk.Frame(frm)
btn_row.pack(fill="x")
def on_install() -> None:
choice["install"] = True
dlg.destroy()
def on_later() -> None:
dlg.destroy()
ttk.Button(btn_row, text="Jetzt aktualisieren", command=on_install).pack(
side="left", padx=(0, 8)
)
ttk.Button(btn_row, text="Später", command=on_later).pack(side="left")
dlg.protocol("WM_DELETE_WINDOW", on_later)
dlg.update_idletasks()
try:
sw = dlg.winfo_screenwidth()
sh = dlg.winfo_screenheight()
dlg.geometry(
f"+{(sw - dlg.winfo_reqwidth()) // 2}+{(sh - dlg.winfo_reqheight()) // 2}"
)
except Exception:
pass
if owns:
dlg.wait_window()
try:
root.destroy()
except Exception:
pass
else:
root.wait_window(dlg)
return bool(choice["install"])
def maybe_prompt_update_on_startup(*, parent: tk.Misc | None = None) -> None:
"""
Beim Startpanel: still pruefen, bei Update freundlich fragen.
404 / kein Manifest / aktuell => nichts anzeigen.
"""
result = check_update_status(startup=True)
status = result.get("status")
_log(f"startup status={status} detail={result.get('detail')}")
if status in ("manifest_unavailable", "current", "channel_mismatch", "error"):
return
if status != "update_available":
return
install_now = _show_friendly_update_dialog(result, parent=parent)
if not install_now:
_log("startup update deferred by user")
return
ready, _reason = validate_update_install_ready(result)
if not ready:
if parent is not None:
messagebox.showinfo(
"Update",
"Das Update-Modul wird vorbereitet.\n"
"Bitte starten Sie die Aktualisierung spaeter erneut.",
parent=parent,
)
else:
root = tk.Tk()
root.withdraw()
messagebox.showinfo(
"Update",
"Das Update-Modul wird vorbereitet.\n"
"Bitte starten Sie die Aktualisierung spaeter erneut.",
parent=root,
)
root.destroy()
return
ok, msg = perform_confirmed_update(result, parent=parent)
if parent is not None:
if ok:
messagebox.showinfo("Update", msg, parent=parent)
else:
messagebox.showinfo("Update", msg, parent=parent)
else:
root = tk.Tk()
root.withdraw()
messagebox.showinfo("Update", msg, parent=root)
root.destroy()
def run_startup_update_check_in_background(*, delay_seconds: float = 1.2) -> None:
"""Nicht-blockierende Startpruefung fuer aza_start_panel.py."""
def worker() -> None:
import time
time.sleep(max(0.0, delay_seconds))
try:
maybe_prompt_update_on_startup()
except Exception as exc:
_log(f"startup check skipped: {type(exc).__name__}")
threading.Thread(
target=worker,
daemon=True,
name="aza-startup-update",
).start()
def check_updates_interactive(*, parent: tk.Misc | None = None) -> None:
"""Manuelle Pruefung (technisch) — fuer aza_controller.py."""
result = check_update_status(startup=False)
status = result.get("status")
if status == "update_available":
install_now = _show_friendly_update_dialog(result, parent=parent)
if install_now:
ok, msg = perform_confirmed_update(result, parent=parent)
messagebox.showinfo("Update", msg, parent=parent)
return
if status == "current":
messagebox.showinfo(
"Update",
f"AZA ist aktuell ({format_version_label()}).",
parent=parent,
)
return
messagebox.showinfo(
"Update",
"Derzeit ist kein Update verfuegbar.",
parent=parent,
)
def show_app_info(*, parent: tk.Misc | None = None) -> None:
local = load_local_version()
messagebox.showinfo(
"AzA",
f"Version: {format_version_label(local)}\n\nAzA Medwork",
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())

View File

@@ -0,0 +1,20 @@
{
"product": "AZA Desktop",
"channel": "stable",
"latest_version": "9.9.9",
"latest_build": "20991231_235959",
"min_supported_version": "1.0.0",
"mandatory": false,
"release_date": "2099-12-31",
"notes_de": [
"Nur fuer lokalen Test — nicht live veroeffentlichen."
],
"files": [
{
"name": "aza_update_test.zip",
"url": "https://example.invalid/aza_update_test.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"size_bytes": 1
}
]
}

View File

@@ -0,0 +1,6 @@
{
"version": "1.2.0",
"build": "20260519_233633",
"channel": "stable",
"app": "AZA Desktop"
}