update
This commit is contained in:
@@ -0,0 +1,897 @@
|
||||
# -*- 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 _center_window(win: tk.Tk, width: int, height: int) -> None:
|
||||
win.update_idletasks()
|
||||
sw = win.winfo_screenwidth()
|
||||
sh = win.winfo_screenheight()
|
||||
x = max(0, (sw - width) // 2)
|
||||
y = max(0, (sh - height) // 2)
|
||||
win.geometry(f"{width}x{height}+{x}+{y}")
|
||||
|
||||
|
||||
def _minichat_url() -> str:
|
||||
"""Technischer URL-Modus minichat=1 (Praxischat)."""
|
||||
try:
|
||||
from aza_empfang_webview import _build_default_empfang_chat_shell_url
|
||||
|
||||
base = _build_default_empfang_chat_shell_url()
|
||||
except Exception:
|
||||
base = (
|
||||
os.environ.get("AZA_EMPFANG_CHAT_SHELL_URL") or ""
|
||||
).strip() or (
|
||||
"https://empfang.aza-medwork.ch/empfang/"
|
||||
"?empfang_chat_shell=1&shell_source=empfang_chat_shell"
|
||||
)
|
||||
try:
|
||||
p = urlparse(base)
|
||||
qs = parse_qs(p.query, keep_blank_values=True)
|
||||
qs["minichat"] = ["1"]
|
||||
return urlunparse(p._replace(query=urlencode(qs, doseq=True)))
|
||||
except Exception:
|
||||
sep = "&" if "?" in base else "?"
|
||||
return f"{base}{sep}minichat=1"
|
||||
|
||||
|
||||
def _resolve_office_executable() -> Path | None:
|
||||
if getattr(sys, "frozen", False):
|
||||
exe_dir = Path(sys.executable).resolve().parent
|
||||
for name in ("aza_desktop.exe", "AzA.exe", "AzA_Start.exe"):
|
||||
p = exe_dir / name
|
||||
if p.is_file():
|
||||
return p
|
||||
meipass = getattr(sys, "_MEIPASS", "")
|
||||
if meipass:
|
||||
p = Path(meipass) / "aza_desktop.exe"
|
||||
if p.is_file():
|
||||
return p
|
||||
for p in (_ROOT / "aza_desktop.exe", _ROOT / "dist" / "aza_desktop" / "aza_desktop.exe"):
|
||||
if p.is_file():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_empfang_shell_executable() -> Path | None:
|
||||
if getattr(sys, "frozen", False):
|
||||
exe_dir = Path(sys.executable).resolve().parent
|
||||
p = exe_dir / "AZA_EmpfangShell.exe"
|
||||
if p.is_file():
|
||||
return p
|
||||
meipass = getattr(sys, "_MEIPASS", "")
|
||||
if meipass:
|
||||
p = Path(meipass) / "AZA_EmpfangShell.exe"
|
||||
if p.is_file():
|
||||
return p
|
||||
for p in (_ROOT / "AZA_EmpfangShell.exe", _ROOT / "dist" / "AZA_EmpfangShell.exe"):
|
||||
if p.is_file():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def start_office() -> tuple[bool, str]:
|
||||
exe = _resolve_office_executable()
|
||||
try:
|
||||
if exe is not None:
|
||||
subprocess.Popen(
|
||||
[str(exe)],
|
||||
cwd=str(exe.parent),
|
||||
close_fds=(sys.platform != "win32"),
|
||||
)
|
||||
return True, f"Gestartet: {exe.name}"
|
||||
script = _ROOT / "basis14.py"
|
||||
if not script.is_file():
|
||||
return False, "basis14.py und aza_desktop.exe wurden nicht gefunden."
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(script)],
|
||||
cwd=str(_ROOT),
|
||||
close_fds=(sys.platform != "win32"),
|
||||
)
|
||||
return True, "AzA Office wird gestartet."
|
||||
except Exception as exc:
|
||||
return False, f"Office konnte nicht gestartet werden: {exc}"
|
||||
|
||||
|
||||
def start_praxischat() -> tuple[bool, str]:
|
||||
"""Praxischat = bestehender MiniChat-WebView-Starter (minichat=1)."""
|
||||
url = _minichat_url()
|
||||
mode_arg = "--shell-mode=empfang_chat_shell"
|
||||
w, h = "350", "700"
|
||||
exe = _resolve_empfang_shell_executable()
|
||||
try:
|
||||
if exe is not None:
|
||||
subprocess.Popen(
|
||||
[str(exe), mode_arg, url, w, h],
|
||||
cwd=str(exe.parent),
|
||||
close_fds=(sys.platform != "win32"),
|
||||
)
|
||||
return True, f"Praxischat wird gestartet ({exe.name})."
|
||||
script = _ROOT / "aza_empfang_webview.py"
|
||||
if not script.is_file():
|
||||
return (
|
||||
False,
|
||||
"aza_empfang_webview.py und AZA_EmpfangShell.exe wurden nicht gefunden.",
|
||||
)
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(script), mode_arg, url, w, h],
|
||||
cwd=str(_ROOT),
|
||||
close_fds=(sys.platform != "win32"),
|
||||
)
|
||||
return True, "Praxischat wird gestartet."
|
||||
except Exception as exc:
|
||||
return False, f"Praxischat konnte nicht gestartet werden: {exc}"
|
||||
|
||||
|
||||
# Alias fuer aeltere HTML-API-Namen
|
||||
start_minichat = start_praxischat
|
||||
|
||||
|
||||
class StartPanelApi:
|
||||
"""pywebview JS-API (Chromium/WebView2)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._window = None
|
||||
|
||||
def bind_window(self, window) -> None:
|
||||
self._window = window
|
||||
|
||||
def _close_on_success(self, ok: bool) -> None:
|
||||
if ok and self._window is not None:
|
||||
try:
|
||||
self._window.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def open_office(self) -> dict:
|
||||
ok, msg = start_office()
|
||||
self._close_on_success(ok)
|
||||
return {"ok": ok, "message": msg}
|
||||
|
||||
def open_praxischat(self) -> dict:
|
||||
ok, msg = start_praxischat()
|
||||
self._close_on_success(ok)
|
||||
return {"ok": ok, "message": msg}
|
||||
|
||||
# Kompatibilitaet aelterer HTML-Handler
|
||||
launch_office = open_office
|
||||
launch_minichat = open_praxischat
|
||||
|
||||
|
||||
def _build_panel_html(asset_base: str) -> str:
|
||||
"""HTML mit http://127.0.0.1:PORT/... Asset-URLs (WebView2-kompatibel)."""
|
||||
base = asset_base.rstrip("/")
|
||||
png = html.escape(f"{base}/assets/matternhorn.png", quote=True)
|
||||
mp4 = ""
|
||||
if _MP4.is_file():
|
||||
mp4 = html.escape(f"{base}/assets/matternhorn-aza-2.mp4", quote=True)
|
||||
favicon = ""
|
||||
if _LOGO.is_file():
|
||||
favicon = (
|
||||
f'<link rel="icon" type="image/png" href="'
|
||||
f'{html.escape(f"{base}/logo.png", quote=True)}"/>'
|
||||
)
|
||||
logo_block = ""
|
||||
if _LOGO.is_file():
|
||||
logo_u = html.escape(f"{base}/logo.png", quote=True)
|
||||
logo_block = (
|
||||
f'<img class="logo" id="logoImg" src="{logo_u}" alt="" '
|
||||
f'onerror="this.remove()"/>'
|
||||
)
|
||||
video_block = ""
|
||||
if mp4:
|
||||
video_block = f"""
|
||||
<div class="bg-wrap">
|
||||
<video id="bgVideo" class="bg-video" autoplay muted loop playsinline preload="auto" poster="{png}">
|
||||
<source src="{mp4}" type="video/mp4"/>
|
||||
</video>
|
||||
<img class="bg-fallback" src="{png}" alt=""/>
|
||||
</div>"""
|
||||
else:
|
||||
video_block = f"""
|
||||
<div class="bg-wrap">
|
||||
<img class="bg-fallback show-fallback" src="{png}" alt=""/>
|
||||
</div>"""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>AzA</title>
|
||||
{favicon}
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
html, body {{
|
||||
width: 100%; height: 100%; overflow: hidden;
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
background: #b8c9d8;
|
||||
}}
|
||||
.bg-wrap {{
|
||||
position: fixed; inset: 0; z-index: 0; overflow: hidden;
|
||||
width: 100vw; height: 100vh;
|
||||
}}
|
||||
.bg-video, .bg-fallback {{
|
||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
}}
|
||||
.bg-fallback {{ display: none; }}
|
||||
.bg-fallback.show-fallback {{ display: block; }}
|
||||
body.no-video .bg-video {{ display: none; }}
|
||||
body.no-video .bg-fallback {{ display: block; }}
|
||||
.fog {{
|
||||
position: fixed; inset: 0; z-index: 1;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.18) 0%,
|
||||
rgba(240, 248, 255, 0.42) 50%,
|
||||
rgba(230, 240, 250, 0.52) 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.42);
|
||||
border: 1px solid rgba(255, 255, 255, 0.58);
|
||||
box-shadow: 0 9px 33px rgba(20, 40, 60, 0.14);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
text-align: center;
|
||||
}}
|
||||
.logo {{
|
||||
width: 43px; height: 43px; object-fit: contain;
|
||||
margin: 0 auto 7px; display: block;
|
||||
}}
|
||||
h1 {{ font-size: 1.38rem; font-weight: 700; color: #1a2a3a; }}
|
||||
.sub {{ margin: 6px 0 15px; font-size: 0.83rem; color: #5a6d7d; }}
|
||||
.btn {{
|
||||
display: block; width: 100%; margin-bottom: 9px;
|
||||
padding: 12px 15px; border: none; border-radius: 9px;
|
||||
font-size: 0.86rem; font-weight: 600; color: #fff;
|
||||
background: #1a5f8a; cursor: pointer;
|
||||
box-shadow: 0 3px 13px rgba(26, 95, 138, 0.28);
|
||||
transition: background 0.15s ease, transform 0.1s ease;
|
||||
}}
|
||||
.btn:last-of-type {{ margin-bottom: 0; }}
|
||||
.btn:hover {{ background: #164f73; }}
|
||||
.btn:active {{ transform: scale(0.98); }}
|
||||
.btn:disabled {{ opacity: 0.65; cursor: wait; }}
|
||||
.foot {{ margin-top: 13px; font-size: 0.67rem; color: #6a7d8c; }}
|
||||
.err {{ display: none; margin-top: 9px; font-size: 0.71rem; color: #b42318; }}
|
||||
.err.show {{ display: block; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{video_block}
|
||||
<div class="fog"></div>
|
||||
<div class="stage">
|
||||
<div class="card">
|
||||
{logo_block}
|
||||
<h1>AzA</h1>
|
||||
<p class="sub">Was möchten Sie öffnen?</p>
|
||||
<button type="button" class="btn" id="btnOffice">AzA Office</button>
|
||||
<button type="button" class="btn" id="btnPraxischat">Praxis Chat</button>
|
||||
<p class="err" id="errBox"></p>
|
||||
<p class="foot">AzA Medwork</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {{
|
||||
function useFallback(reason) {{
|
||||
document.body.classList.add("no-video");
|
||||
if (reason) console.warn("START_PANEL_VIDEO_FALLBACK", reason);
|
||||
}}
|
||||
function initVideo() {{
|
||||
var v = document.getElementById("bgVideo");
|
||||
if (!v) {{
|
||||
useFallback("no_video_element");
|
||||
return;
|
||||
}}
|
||||
v.muted = true;
|
||||
v.playsInline = true;
|
||||
v.setAttribute("playsinline", "");
|
||||
v.addEventListener("error", function () {{
|
||||
useFallback("error code=" + (v.error ? v.error.code : "?"));
|
||||
}});
|
||||
v.addEventListener("playing", function () {{
|
||||
document.body.classList.remove("no-video");
|
||||
}});
|
||||
v.addEventListener("canplay", function () {{
|
||||
document.body.classList.remove("no-video");
|
||||
v.play().catch(function () {{ useFallback("play_rejected"); }});
|
||||
}});
|
||||
setTimeout(function () {{
|
||||
if (v.error) useFallback("timeout_error");
|
||||
else if (v.readyState < 2 && v.networkState === 3) useFallback("timeout_no_source");
|
||||
}}, 15000);
|
||||
try {{ v.load(); }} catch (e) {{ useFallback("load_failed"); }}
|
||||
v.play().catch(function () {{}});
|
||||
}}
|
||||
if (document.readyState === "loading") {{
|
||||
document.addEventListener("DOMContentLoaded", initVideo);
|
||||
}} else {{
|
||||
initVideo();
|
||||
}}
|
||||
|
||||
function showErr(msg) {{
|
||||
var el = document.getElementById("errBox");
|
||||
el.textContent = msg || "";
|
||||
el.classList.toggle("show", !!msg);
|
||||
}}
|
||||
function setBusy(busy) {{
|
||||
document.getElementById("btnOffice").disabled = busy;
|
||||
document.getElementById("btnPraxischat").disabled = busy;
|
||||
}}
|
||||
async function callApi(method) {{
|
||||
if (!window.pywebview || !pywebview.api || !pywebview.api[method]) {{
|
||||
showErr("Start-API nicht verfügbar.");
|
||||
return;
|
||||
}}
|
||||
setBusy(true);
|
||||
showErr("");
|
||||
try {{
|
||||
var r = await pywebview.api[method]();
|
||||
if (r && r.ok) return;
|
||||
showErr((r && r.message) ? r.message : "Start fehlgeschlagen.");
|
||||
}} catch (e) {{
|
||||
showErr(String(e && e.message ? e.message : e));
|
||||
}} finally {{
|
||||
setBusy(false);
|
||||
}}
|
||||
}}
|
||||
document.getElementById("btnOffice").addEventListener("click", function () {{
|
||||
callApi("open_office");
|
||||
}});
|
||||
document.getElementById("btnPraxischat").addEventListener("click", function () {{
|
||||
callApi("open_praxischat");
|
||||
}});
|
||||
}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _webview_storage_dir() -> str:
|
||||
appdata = os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") or ""
|
||||
base = Path(appdata) / "AzA" / "StartPanelWebView" if appdata else _ROOT / ".aza_start_panel_webview"
|
||||
try:
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return str(base)
|
||||
|
||||
|
||||
def _has_pywebview() -> bool:
|
||||
try:
|
||||
import webview # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _should_use_webview() -> bool:
|
||||
if os.environ.get("AZA_START_PANEL_FORCE_TK", "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
):
|
||||
return False
|
||||
return _has_pywebview()
|
||||
|
||||
|
||||
def _icon_path() -> str | None:
|
||||
return _resolve_native_icon_path()
|
||||
|
||||
|
||||
def _ensure_win32_app_user_model_id() -> None:
|
||||
"""Eigener Taskleisten-Eintrag statt python.exe (muss vor dem ersten Fenster)."""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(_WIN_APP_ID)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_native_icon_path() -> str | None:
|
||||
"""logo.ico bevorzugt; sonst logo.png -> temporaeres .ico (nur fuer Fenster-Icon)."""
|
||||
ico = _ROOT / "logo.ico"
|
||||
if ico.is_file():
|
||||
return str(ico.resolve())
|
||||
png = _ROOT / "logo.png"
|
||||
if png.is_file() and _HAS_PIL:
|
||||
cache = _ROOT / ".aza_start_panel_icon_cache.ico"
|
||||
try:
|
||||
if (not cache.is_file()) or cache.stat().st_mtime < png.stat().st_mtime:
|
||||
img = Image.open(png)
|
||||
img.save(
|
||||
cache,
|
||||
format="ICO",
|
||||
sizes=[(16, 16), (32, 32), (48, 48)],
|
||||
)
|
||||
return str(cache.resolve())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _webview_hwnd(window) -> int | None:
|
||||
native = getattr(window, "native", None)
|
||||
if native is None:
|
||||
return None
|
||||
try:
|
||||
handle = getattr(native, "Handle", None)
|
||||
if handle is not None:
|
||||
return int(handle.ToInt32())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _win32_find_window_hwnd(title: str = "AzA") -> int | None:
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
import ctypes
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
|
||||
found: list[int] = []
|
||||
|
||||
def _enum(hwnd, _lparam) -> bool:
|
||||
if not user32.IsWindowVisible(hwnd):
|
||||
return True
|
||||
buf = ctypes.create_unicode_buffer(256)
|
||||
if user32.GetWindowTextW(hwnd, buf, 256) and buf.value == title:
|
||||
found.append(int(hwnd))
|
||||
return False
|
||||
return True
|
||||
|
||||
WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
|
||||
user32.EnumWindows(WNDENUMPROC(_enum), 0)
|
||||
return found[0] if found else None
|
||||
|
||||
|
||||
def _win32_set_window_icon(hwnd: int, icon_path: str) -> bool:
|
||||
"""WM_SETICON + Klassen-Icon fuer Titelzeile und Taskleiste."""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
import ctypes
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
path = os.path.abspath(icon_path)
|
||||
if not os.path.isfile(path):
|
||||
return False
|
||||
WM_SETICON = 0x0080
|
||||
ICON_SMALL = 0
|
||||
ICON_BIG = 1
|
||||
IMAGE_ICON = 1
|
||||
LR_LOADFROMFILE = 0x00000010
|
||||
GCLP_HICON = -14
|
||||
GCLP_HICONSM = -34
|
||||
set_class = getattr(user32, "SetClassLongPtrW", None) or user32.SetClassLongW
|
||||
ok = False
|
||||
h_default = user32.LoadImageW(None, path, IMAGE_ICON, 0, 0, LR_LOADFROMFILE)
|
||||
h_small = user32.LoadImageW(None, path, IMAGE_ICON, 16, 16, LR_LOADFROMFILE)
|
||||
h_big = user32.LoadImageW(None, path, IMAGE_ICON, 32, 32, LR_LOADFROMFILE) or h_default
|
||||
if h_small:
|
||||
user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, h_small)
|
||||
set_class(hwnd, GCLP_HICONSM, h_small)
|
||||
ok = True
|
||||
if h_big:
|
||||
user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, h_big)
|
||||
set_class(hwnd, GCLP_HICON, h_big)
|
||||
ok = True
|
||||
return ok
|
||||
|
||||
|
||||
def _apply_native_window_icon(window) -> bool:
|
||||
path = _resolve_native_icon_path()
|
||||
if not path:
|
||||
print("[AzA Startpanel] WINDOW_ICON=missing")
|
||||
return False
|
||||
hwnd = _webview_hwnd(window) or _win32_find_window_hwnd("AzA")
|
||||
applied = bool(hwnd and _win32_set_window_icon(hwnd, path))
|
||||
print(
|
||||
f"[AzA Startpanel] WINDOW_ICON={'ok' if applied else 'pending'} "
|
||||
f"file={Path(path).name} hwnd={hwnd or 0}"
|
||||
)
|
||||
return applied
|
||||
|
||||
|
||||
def _schedule_native_window_icon(window) -> None:
|
||||
for delay in (0.15, 0.45, 1.0, 2.5):
|
||||
threading.Timer(delay, lambda: _apply_native_window_icon(window)).start()
|
||||
|
||||
|
||||
def _bind_native_icon_on_loaded(window) -> None:
|
||||
def _on_loaded() -> None:
|
||||
_apply_native_window_icon(window)
|
||||
|
||||
try:
|
||||
window.events.loaded += _on_loaded
|
||||
window.events.shown += _on_loaded
|
||||
except Exception:
|
||||
pass
|
||||
_schedule_native_window_icon(window)
|
||||
|
||||
|
||||
def _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()
|
||||
try:
|
||||
win = webview.create_window(
|
||||
"AzA",
|
||||
url=start_url,
|
||||
width=_W,
|
||||
height=_H,
|
||||
resizable=False,
|
||||
js_api=api,
|
||||
background_color="#b8c9d8",
|
||||
)
|
||||
api.bind_window(win)
|
||||
_bind_native_icon_on_loaded(win)
|
||||
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))
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user