Files
aza/AzA march 2026/aza_start_panel.py
2026-06-18 13:47:45 +02:00

1257 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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
import requests # module-level fuer SSO-Shell-Session + Testbarkeit
import aza_persistence # lokales Profil fuer Chat-Office-SSO
_POPEN_NO_CONSOLE: dict = (
{"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}
)
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 _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 ""
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"),
**_POPEN_NO_CONSOLE,
)
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"),
**_POPEN_NO_CONSOLE,
)
return True, "AzA Office wird gestartet."
except Exception as exc:
return False, f"Office konnte nicht gestartet werden: {exc}"
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)."""
url = _minichat_url()
mode_arg = "--shell-mode=empfang_chat_shell"
w, h = "350", "700"
pre = list(extra_args or [])
exe = _resolve_empfang_shell_executable()
try:
if exe is not None:
subprocess.Popen(
[str(exe), *pre, mode_arg, url, w, h],
cwd=str(exe.parent),
close_fds=(sys.platform != "win32"),
**_POPEN_NO_CONSOLE,
)
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), *pre, mode_arg, url, w, h],
cwd=str(_ROOT),
close_fds=(sys.platform != "win32"),
**_POPEN_NO_CONSOLE,
)
return True, "Praxischat wird gestartet."
except Exception as exc:
return False, f"Praxischat konnte nicht gestartet werden: {exc}"
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()
# 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()"/>'
)
ver_label = _installed_version_label()
ver_foot = f" · {html.escape(ver_label, quote=True)}" if ver_label else ""
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{ver_foot}</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)
# 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.
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")
_ver_label = _installed_version_label()
_foot_text = "AzA Medwork" + (f" · {_ver_label}" if _ver_label else "")
tk.Label(
card, text=_foot_text, 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))
# ARCHITEKTUR: Kein Updatecheck im Startpanel (auch nicht im Tk-Fallback).
# Einziger Update-Owner ist AzA Office.
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())