# -*- 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'' ) 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) 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("", 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())