# -*- 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 _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 _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 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"), **_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), 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}" # 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'' ) logo_block = "" if _LOGO.is_file(): logo_u = html.escape(f"{base}/logo.png", quote=True) logo_block = ( f'' ) video_block = "" if mp4: video_block = f"""
""" else: video_block = f"""
""" return f""" AzA {favicon} {video_block}
{logo_block}

AzA

Was möchten Sie öffnen?

AzA Medwork

""" 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, use_native_dialog=True, ) 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, lambda: run_startup_update_check_in_background( parent=self.root, schedule_on_main=lambda fn: self.root.after(0, fn), ), ) 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("", lambda _e: btn.configure(bg=_ACCENT_HOVER)) btn.bind("", 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())