This commit is contained in:
2026-05-12 01:21:25 +02:00
parent 8261a281c4
commit 96c1029d91
44 changed files with 166486 additions and 199 deletions

View File

@@ -19,6 +19,7 @@ import os
import subprocess
import sys
import threading
import time
from pathlib import Path
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
@@ -124,7 +125,7 @@ def _focus_other_empfang_host_window() -> bool:
return True
buf = ctypes.create_unicode_buffer(260)
user32.GetWindowTextW(hwnd, buf, 260)
if buf.value != "AzA-Empfang":
if not (buf.value == "AzA-Empfang" or buf.value.startswith("AzA-Empfang")):
return True
pid = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
@@ -197,7 +198,13 @@ class EmpfangWebviewApi:
self._window = window
def toggle_on_top(self):
"""Wechselt always-on-top zuverlaessig: pywebview-Setter UND Win32 SetWindowPos."""
"""Wechselt always-on-top: persistiert sofort, fuehrt OS-Setter NICHT-BLOCKIEREND aus.
Wichtig: gibt sofort den neuen Booleschen Zustand zurueck. Die WinForms-/Win32-
Aufrufe (`win.on_top = X`, SetWindowPos) laufen in einem Daemon-Thread, damit
der pywebview-API-Thread und damit auch der UI-Thread NICHT durch synchrone
WinForms-Marshalls blockiert werden. Kein SetForegroundWindow im Pin-Pfad.
"""
if not self._pin_lock.acquire(blocking=False):
return self._on_top
try:
@@ -207,40 +214,117 @@ class EmpfangWebviewApi:
self._pin_lock.release()
_save_shell_pin_on_top(new_val)
print(
f"[EMPFANG_TOPMOST] stage=manual_toggle requested={new_val}",
flush=True,
)
win = self._window
def _apply_async() -> None:
err_type = ""
try:
if win is not None:
win.on_top = new_val
except Exception as exc_a:
err_type = type(exc_a).__name__
try:
_apply_win32_topmost(new_val)
except Exception as exc_b:
err_type = err_type or type(exc_b).__name__
if err_type:
print(
f"[EMPFANG_TOPMOST] stage=apply_error err_type={err_type}",
flush=True,
)
else:
print(
f"[EMPFANG_TOPMOST] stage=manual_toggle ok=True value={new_val}",
flush=True,
)
try:
win = self._window
if win is not None:
win.on_top = new_val
except Exception:
pass
try:
_apply_win32_topmost(new_val)
except Exception:
pass
threading.Thread(target=_apply_async, daemon=True).start()
except Exception as exc:
print(
f"[EMPFANG_TOPMOST] stage=manual_toggle_thread_spawn_failed err_type={type(exc).__name__}",
flush=True,
)
return new_val
def get_on_top(self):
return self._on_top
def apply_saved_on_top(self) -> dict:
"""Persistierten Pin-Zustand einmal anwenden (nach pywebviewready vom JS aufgerufen).
"""Pin-Wiederanwendung beim Start: NICHT-BLOCKIEREND, optional verzoegert.
Synchron im pywebview-API-Thread, KEIN Daemon-Thread, KEIN loaded-Hook.
Setzt zusaetzlich SetWindowPos(HWND_TOPMOST), weil pywebview/WinForms
unter WebView2 sonst oft kein echtes TopMost erzwingt.
Frueher wurde ``win.on_top = X`` und SetWindowPos(HWND_TOPMOST) hier
SYNCHRON im API-/UI-Aufrufpfad gemacht. Das konnte ``AZA_EmpfangShell.exe``
beim WebView2-Coldstart einfrieren ("Keine Rueckmeldung"), bevor die
Chat-Oberflaeche geladen war.
Verhalten ab jetzt:
* Standard: NICHTS tun (Pin wird erst auf manuellen Klick erneut angewendet).
* Optional verzoegert NEU anwenden, wenn Umgebungsvariable
AZA_EMPFANG_AUTO_TOPMOST_ON_START=1 gesetzt ist. Dann erfolgt der
tatsaechliche Aufruf in einem kurzen Daemon-Thread nach ~3 s.
* Kein SetForegroundWindow, kein Loop, kein Spam.
* Liefert sofort {"ok": True, "on_top": <gespeicherter Zustand>} zurueck.
"""
saved = bool(self._on_top)
auto_env = (os.environ.get("AZA_EMPFANG_AUTO_TOPMOST_ON_START") or "").strip()
do_auto = auto_env == "1" and saved
if not do_auto:
print(
f"[EMPFANG_TOPMOST] stage=startup_apply_skipped saved={saved} "
f"auto_env_set={int(auto_env == '1')}",
flush=True,
)
return {"ok": True, "on_top": saved, "applied": False}
win = self._window
def _delayed_apply() -> None:
try:
time.sleep(3.0)
except Exception:
pass
err_type = ""
try:
if win is not None:
win.on_top = saved
except Exception as exc_a:
err_type = type(exc_a).__name__
try:
_apply_win32_topmost(saved)
except Exception as exc_b:
err_type = err_type or type(exc_b).__name__
if err_type:
print(
f"[EMPFANG_TOPMOST] stage=apply_error err_type={err_type}",
flush=True,
)
else:
print(
f"[EMPFANG_TOPMOST] stage=startup_apply_done value={saved}",
flush=True,
)
try:
win = self._window
if win is not None:
win.on_top = self._on_top
except Exception:
pass
try:
_apply_win32_topmost(self._on_top)
except Exception:
pass
return {"ok": True, "on_top": bool(self._on_top)}
threading.Thread(target=_delayed_apply, daemon=True).start()
print(
f"[EMPFANG_TOPMOST] stage=startup_apply_scheduled saved={saved} delay_s=3",
flush=True,
)
except Exception as exc:
print(
f"[EMPFANG_TOPMOST] stage=startup_apply_thread_spawn_failed "
f"err_type={type(exc).__name__}",
flush=True,
)
return {"ok": True, "on_top": saved, "applied": False, "scheduled": True}
def open_minichat(self, href: str) -> dict:
"""Zweites schmales Empfang-Fenster (subprocess wie Desktop-Starter).
@@ -391,6 +475,32 @@ class EmpfangWebviewApi:
stop_global_patient_nr_pick()
return {"ok": True}
def play_notification_sound(self, kind: str = "") -> dict:
"""Spielt einen kurzen Systemton ab (Windows: winsound.MessageBeep).
Native, non-blocking, unabhaengig von Browser-Autoplay-Policies und
Hintergrundtab-Drosselung. Keine Patientendaten/Chatdaten/Tokens werden
geloggt oder verarbeitet. ``kind`` ist nur ein Hinweis (z.B. "incoming").
"""
def _do() -> None:
if sys.platform != "win32":
return
try:
import winsound # noqa: WPS433 (lazy)
# MB_OK ist nicht zu laut, gut hoerbar; SND_ASYNC blockiert nicht.
winsound.MessageBeep(winsound.MB_OK)
except Exception as exc:
print(
f"[empfang] play_notification_sound failed: {type(exc).__name__}",
flush=True,
)
try:
threading.Thread(target=_do, daemon=True).start()
except Exception:
pass
return {"ok": True, "kind": str(kind or "")[:32]}
def bring_to_front(self) -> dict:
"""JS-API: Empfang-Huelle in den Vordergrund holen. Nicht-blockierend.
@@ -418,7 +528,26 @@ class EmpfangWebviewApi:
import ctypes # noqa: WPS433
user32 = ctypes.windll.user32
hwnd = user32.FindWindowW(None, "AzA-Empfang")
handles: list[int] = []
my_pid = int(os.getpid())
@ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
def _enum(hwnd, _lp):
if not user32.IsWindowVisible(hwnd):
return True
buf = ctypes.create_unicode_buffer(260)
user32.GetWindowTextW(hwnd, buf, 260)
t = buf.value or ""
if not (t == "AzA-Empfang" or t.startswith("AzA-Empfang")):
return True
pid = ctypes.c_ulong()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if int(pid.value) == my_pid and int(hwnd):
handles.append(int(hwnd))
return True
user32.EnumWindows(_enum, 0)
hwnd = handles[0] if handles else 0
if not hwnd:
return
sw_restore = 9
@@ -591,13 +720,27 @@ def main(argv: list[str] | None = None) -> int:
)
return 11
def _window_title_for_start_url(start_url: str) -> str:
"""Titel nach Start-URL: Arzt-Desktop-Shell vs. separate Chat-Huelle vs. manueller Test."""
u = (start_url or "")
low = u.lower()
if "/empfang/shell/launch" in u and "token=" in low:
if "target=empfang_chat_shell" in low or "target%3Dempfang_chat_shell" in low:
return "AzA-Empfang \u00b7 Chat-H\u00fclle"
return "AzA-Empfang \u00b7 Desktop"
if "shell_source=aza_desktop" in u or "shell_source%3Daza_desktop" in low:
return "AzA-Empfang \u00b7 Desktop"
return "AzA-Empfang"
api = EmpfangWebviewApi()
start_kw = {"storage_path": storage_path, "private_mode": False}
_ico = _empfang_shell_icon_path()
if _ico:
start_kw["icon"] = _ico
try:
win = webview.create_window("AzA-Empfang", url, width=w, height=h, js_api=api)
win = webview.create_window(
_window_title_for_start_url(url), url, width=w, height=h, js_api=api,
)
api.bind_window(win)
webview.start(**start_kw)
return 0