Files
aza/AzA march 2026/aza_start_panel.py

1257 lines
41 KiB
Python
Raw Normal View History

2026-05-23 21:31:34 +02:00
# -*- 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
2026-05-28 18:58:38 +02:00
2026-06-18 13:47:45 +02:00
import requests # module-level fuer SSO-Shell-Session + Testbarkeit
import aza_persistence # lokales Profil fuer Chat-Office-SSO
2026-05-28 18:58:38 +02:00
_POPEN_NO_CONSOLE: dict = (
{"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}
)
2026-05-23 21:31:34 +02:00
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)
2026-06-10 22:55:03 +02:00
def _installed_version_label() -> str:
"""Installierte Version aus kanonischer version.json (gleiche Quelle wie Office/Updater).
Keine separat fest kodierte Versionsnummer. Diagnose-Log nur mit Pfad und
Versionsnummer (keine Tokens/Patientendaten).
"""
try:
from aza_update_core import load_local_version, find_local_version_file
info = load_local_version()
ver = str(info.get("version") or "").strip()
try:
vf = find_local_version_file()
src = str(vf) if vf else "(fallback aza_version.py)"
except Exception:
src = "(unbekannt)"
print(f"[AzA Startpanel] INSTALLED_VERSION={ver or '?'} SOURCE={src}")
startpanel_exe = (
str(Path(sys.executable)) if getattr(sys, "frozen", False) else str(_ROOT)
)
print(f"[AzA Startpanel] STARTPANEL_PATH={startpanel_exe}")
if ver:
return f"Version {ver}"
except Exception as exc:
print(f"[AzA Startpanel] VERSION_READ_SKIPPED={type(exc).__name__}")
return ""
2026-05-23 21:31:34 +02:00
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"),
2026-05-28 18:58:38 +02:00
**_POPEN_NO_CONSOLE,
2026-05-23 21:31:34 +02:00
)
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"),
2026-05-28 18:58:38 +02:00
**_POPEN_NO_CONSOLE,
2026-05-23 21:31:34 +02:00
)
return True, "AzA Office wird gestartet."
except Exception as exc:
return False, f"Office konnte nicht gestartet werden: {exc}"
2026-06-18 13:47:45 +02:00
def _startpanel_backend_url() -> str:
"""Backend-URL standalone (Env MEDWORK_BACKEND_URL oder backend_url.txt neben EXE/Root)."""
v = (os.environ.get("MEDWORK_BACKEND_URL") or "").strip()
if v:
return v.rstrip("/")
for base in (
Path(sys.executable).resolve().parent if getattr(sys, "frozen", False) else None,
_ROOT,
):
if not base:
continue
p = base / "backend_url.txt"
if p.is_file():
try:
line = (p.read_text(encoding="utf-8").strip().splitlines() or [""])[0].strip()
if line:
return line.rstrip("/")
except Exception:
pass
return ""
def _startpanel_backend_token() -> str:
"""API-Token standalone (backend_token.txt neben EXE/Root oder MEDWORK_API_TOKENS/_TOKEN)."""
for base in (
Path(sys.executable).resolve().parent if getattr(sys, "frozen", False) else None,
_ROOT,
):
if not base:
continue
p = base / "backend_token.txt"
if p.is_file():
try:
line = (p.read_text(encoding="utf-8").strip().splitlines() or [""])[0].strip()
if line:
return line
except Exception:
pass
env = (os.environ.get("MEDWORK_API_TOKENS") or "").strip()
if env:
return env.split(",")[0].strip()
return (os.environ.get("MEDWORK_API_TOKEN") or "").strip()
def _startpanel_logout_marker_active() -> bool:
"""True wenn der Benutzer sich bewusst aus dem Chat abgemeldet hat.
Verhindert sofortigen ungefragten Auto-Login nach bewusstem Logout. Marker ist eine
lokale Datei im AzA-Datenverzeichnis (kein Token, keine Credentials).
"""
try:
from aza_persistence import get_writable_data_dir
p = Path(get_writable_data_dir()) / "aza_chat_logout_marker"
return p.is_file()
except Exception:
return False
def _startpanel_office_running() -> bool:
"""Laeuft AzA Office (aza_desktop.exe)? Bevorzugt bestehende Pruefung, sonst leichter Fallback."""
try:
import aza_chat_desktop_host
return bool(aza_chat_desktop_host._office_desktop_running())
except Exception:
pass
if sys.platform != "win32":
return False
try:
r = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq aza_desktop.exe", "/NH"],
capture_output=True, text=True, timeout=5,
**_POPEN_NO_CONSOLE,
)
return "aza_desktop.exe" in (r.stdout or "").lower()
except Exception:
return False
def _startpanel_fetch_shell_token() -> str | None:
"""Erzeugt einen kurzlebigen Shell-Session-Token aus dem lokalen Profil.
Nutzt den bestehenden, serverseitig validierten SSO-Endpunkt
``POST /empfang/shell/session`` (validiert practice_id + empfang_user_id).
Kein Passwort, keine Klartext-Credentials. Token nur als Rueckgabewert (kurzlebig).
"""
try:
prof = aza_persistence.load_user_profile() or {}
except Exception:
return None
pid = (prof.get("practice_id") or "").strip()
uid = (prof.get("empfang_user_id") or "").strip()
if not pid or not uid:
return None
bu = _startpanel_backend_url()
tok = _startpanel_backend_token()
if not bu or not tok:
return None
hdrs = {
"X-API-Token": tok,
"X-Practice-Id": pid,
"X-AzA-Empfang-User-Id": uid,
}
try:
r = requests.post(f"{bu}/empfang/shell/session", headers=hdrs, json={}, timeout=20)
except Exception:
return None
if getattr(r, "status_code", None) != 200:
return None
try:
data = r.json()
except Exception:
return None
if not isinstance(data, dict):
return None
shell_tok = (data.get("shell_token") or "").strip()
return shell_tok or None
def _launch_praxischat_process(extra_args: list[str] | None = None) -> tuple[bool, str]:
"""Startet die Empfang-Chat-Huelle (minichat). extra_args z.B. ['--handoff-token=...'].
Der Handoff-Token wird nur als Prozessargument an die eigene Huelle uebergeben; die Huelle
(aza_empfang_webview) entfernt ihn aus argv und loggt ihn als redacted (kein URL-/Log-Leak)."""
2026-05-23 21:31:34 +02:00
url = _minichat_url()
mode_arg = "--shell-mode=empfang_chat_shell"
w, h = "350", "700"
2026-06-18 13:47:45 +02:00
pre = list(extra_args or [])
2026-05-23 21:31:34 +02:00
exe = _resolve_empfang_shell_executable()
try:
if exe is not None:
subprocess.Popen(
2026-06-18 13:47:45 +02:00
[str(exe), *pre, mode_arg, url, w, h],
2026-05-23 21:31:34 +02:00
cwd=str(exe.parent),
close_fds=(sys.platform != "win32"),
2026-05-28 18:58:38 +02:00
**_POPEN_NO_CONSOLE,
2026-05-23 21:31:34 +02:00
)
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(
2026-06-18 13:47:45 +02:00
[sys.executable, str(script), *pre, mode_arg, url, w, h],
2026-05-23 21:31:34 +02:00
cwd=str(_ROOT),
close_fds=(sys.platform != "win32"),
2026-05-28 18:58:38 +02:00
**_POPEN_NO_CONSOLE,
2026-05-23 21:31:34 +02:00
)
return True, "Praxischat wird gestartet."
except Exception as exc:
return False, f"Praxischat konnte nicht gestartet werden: {exc}"
2026-06-18 13:47:45 +02:00
def _show_office_setup_required_window() -> bool:
"""Prioritaet 3: AzA-Design-Fenster 'AzA Office muss einmal eingerichtet werden'.
Kein graues Standard-Tkinter, kein roter/gruener Button. Button 'AzA Office oeffnen'
startet AzA Office und schliesst das Fenster. Rueckgabe True wenn Fenster gezeigt wurde.
"""
_BG = "#EEF4F8"
_HDR = "#1A4D6D"
_WHT = "#FFFFFF"
_TXT = "#1A3D55"
_SUB = "#5A7A90"
_ACC = "#1A5F8A"
_ACC_HOVER = "#164F73"
_FF = "Segoe UI"
try:
win = tk.Toplevel() if tk._default_root is not None else tk.Tk()
except Exception:
try:
win = tk.Tk()
except Exception:
return False
try:
win.title("AzA Chat")
win.configure(bg=_BG)
win.resizable(False, False)
W, H = 460, 320
try:
sw, sh = win.winfo_screenwidth(), win.winfo_screenheight()
win.geometry(f"{W}x{H}+{max(0,(sw-W)//2)}+{max(0,(sh-H)//2)}")
except Exception:
win.geometry(f"{W}x{H}")
hdr = tk.Frame(win, bg=_HDR, padx=20, pady=16)
hdr.pack(fill="x")
tk.Label(hdr, text="AzA Chat", bg=_HDR, fg=_WHT,
font=(_FF, 14, "bold")).pack(anchor="w")
tk.Label(hdr, text="Einrichtung erforderlich", bg=_HDR, fg="#AFCBE0",
font=(_FF, 9)).pack(anchor="w", pady=(2, 0))
card = tk.Frame(win, bg=_WHT, padx=22, pady=20)
card.pack(fill="both", expand=True, padx=16, pady=16)
tk.Label(card, text="AzA Office muss einmal eingerichtet werden",
bg=_WHT, fg=_TXT, font=(_FF, 11, "bold"),
wraplength=380, justify="left").pack(anchor="w")
tk.Label(card,
text="Damit der Chat Ihre Benutzer- und Praxisidentitaet sicher "
"uebernehmen kann, oeffnen Sie bitte einmal AzA Office und "
"richten Ihr Profil ein. Danach meldet sich der Chat automatisch an.",
bg=_WHT, fg=_SUB, font=(_FF, 9),
wraplength=380, justify="left").pack(anchor="w", pady=(10, 18))
def _open_office():
try:
ok, _msg = start_office()
except Exception:
ok = False
try:
win.destroy()
except Exception:
pass
btn = tk.Button(card, text="AzA Office oeffnen", command=_open_office,
bg=_ACC, fg=_WHT, activebackground=_ACC_HOVER, activeforeground=_WHT,
font=(_FF, 10, "bold"), relief="flat", bd=0,
padx=18, pady=9, cursor="hand2")
btn.pack(anchor="w")
btn.bind("<Enter>", lambda _e: btn.configure(bg=_ACC_HOVER))
btn.bind("<Leave>", lambda _e: btn.configure(bg=_ACC))
try:
win.lift()
win.attributes("-topmost", True)
win.after(600, lambda: win.attributes("-topmost", False))
except Exception:
pass
# Eigenstaendiges Fenster: nur eigenen Loop fahren, wenn dieser Prozess noch keinen hat.
try:
if tk._default_root is win:
win.mainloop()
except Exception:
pass
return True
except Exception:
try:
win.destroy()
except Exception:
pass
return False
def start_praxischat() -> tuple[bool, str]:
"""Praxischat-Start mit Office-SSO (kein separates Chat-Login bei vorhandener Identitaet).
Reihenfolge (kleinste sichere Loesung, bestehende Pfade wiederverwendet):
1. Office laeuft -> bestehende IPC, Office oeffnet die Huelle authentifiziert.
2. Profil vorhanden + kein bewusster Logout -> Shell-Session-Token holen, Huelle per
--handoff-token automatisch anmelden.
3. keine Identitaet -> Einrichtungsfenster ("AzA Office oeffnen"), kein Dauer-Login.
Sicherer Fallback (Profil vorhanden, aber Token fehlgeschlagen/Logout) -> normale Huelle.
"""
# Prioritaet 1: laufendes Office verwenden (kein zweiter Login, kein Token noetig).
try:
if _startpanel_office_running():
import aza_empfang_shell_surface
aza_empfang_shell_surface.request_office_open_empfang_chat_shell_ipc()
return True, "Praxischat wird ueber das laufende AzA Office geoeffnet."
except Exception:
pass
# Identitaet pruefen.
try:
prof = aza_persistence.load_user_profile() or {}
except Exception:
prof = {}
has_identity = bool((prof.get("practice_id") or "").strip()
and (prof.get("empfang_user_id") or "").strip())
# Prioritaet 2: Office aus, gueltiges Profil, kein bewusster Logout -> Token-Handoff.
if has_identity and not _startpanel_logout_marker_active():
tok = _startpanel_fetch_shell_token()
if tok:
return _launch_praxischat_process(["--handoff-token=" + tok])
# Token fehlgeschlagen/abgelaufen: sicherer Fallback auf normale Huelle (kein Loop).
return _launch_praxischat_process()
# Profil vorhanden, aber bewusster Logout -> normale Huelle (Benutzer meldet sich neu an).
if has_identity:
return _launch_praxischat_process()
# Prioritaet 3: keine Office-Identitaet -> einmaliges Einrichtungsfenster statt Dauer-Login.
if _show_office_setup_required_window():
return True, "AzA Office muss einmal eingerichtet werden."
# Fallback nur falls Setup-Fenster nicht moeglich: bestehende Huelle (zeigt Login).
return _launch_praxischat_process()
2026-05-23 21:31:34 +02:00
# 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()"/>'
)
2026-06-10 22:55:03 +02:00
ver_label = _installed_version_label()
ver_foot = f" · {html.escape(ver_label, quote=True)}" if ver_label else ""
2026-05-23 21:31:34 +02:00
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>
2026-06-10 22:55:03 +02:00
<p class="foot">AzA Medwork{ver_foot}</p>
2026-05-23 21:31:34 +02:00
</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)
2026-06-10 22:55:03 +02:00
# ARCHITEKTUR: Das Startpanel fuehrt KEINE Updatepruefung mehr aus.
# Einziger Update-Owner ist AzA Office (basis14.py). Das Panel zeigt
# nur die installierte Version (Footer) und startet niemals einen
# Updater/Installer und schreibt kein Pending.
2026-05-23 21:31:34 +02:00
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")
2026-06-10 22:55:03 +02:00
_ver_label = _installed_version_label()
_foot_text = "AzA Medwork" + (f" · {_ver_label}" if _ver_label else "")
2026-05-23 21:31:34 +02:00
tk.Label(
2026-06-10 22:55:03 +02:00
card, text=_foot_text, font=_FONT_FOOT, fg=_SUBTLE, bg=_CARD_BG
2026-05-23 21:31:34 +02:00
).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))
2026-06-10 22:55:03 +02:00
# ARCHITEKTUR: Kein Updatecheck im Startpanel (auch nicht im Tk-Fallback).
# Einziger Update-Owner ist AzA Office.
2026-05-23 21:31:34 +02:00
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())