This commit is contained in:
2026-06-13 22:47:31 +02:00
parent add3da5177
commit d1446fc452
8032 changed files with 2650751 additions and 1551 deletions

View File

@@ -181,12 +181,59 @@ def _apply_win32_topmost(value: bool) -> bool:
return False
def _focus_other_empfang_host_window(title_prefixes: tuple[str, ...] = ("AzA-Empfang",)) -> bool:
def _hwnd_process_alive(hwnd: int) -> bool:
"""True wenn das Fenster existiert und der zugehoerige Prozess noch laeuft."""
if sys.platform != "win32":
return False
try:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
if not user32.IsWindow(int(hwnd)):
return False
pid = wintypes.DWORD()
user32.GetWindowThreadProcessId(int(hwnd), ctypes.byref(pid))
if not int(pid.value):
return False
hproc = kernel32.OpenProcess(0x1000, False, int(pid.value))
if not hproc:
return False
try:
exit_code = wintypes.DWORD()
if not kernel32.GetExitCodeProcess(hproc, ctypes.byref(exit_code)):
return False
return int(exit_code.value) == 259
finally:
kernel32.CloseHandle(hproc)
except Exception:
return False
def _window_title_matches(t: str, *, prefixes: tuple[str, ...] = (), exact_titles: tuple[str, ...] = ()) -> bool:
"""Fenstertitel-Match: exakt ODER Prefix (Prefix darf NICHT Desktop-Huelle treffen)."""
title = str(t or "")
exact = tuple(x for x in exact_titles if x)
if exact:
return any(title == e for e in exact)
pref = tuple(p for p in prefixes if p)
return any(title == p or title.startswith(p) for p in pref)
def _focus_other_empfang_host_window(
title_prefixes: tuple[str, ...] = ("AzA-Empfang",),
*,
exact_titles: tuple[str, ...] = (),
) -> bool:
"""Bringt ein anderes AzA-Empfang-Fenster (anderer Prozess) in den Vordergrund.
``title_prefixes`` schraenkt das Match auf den eigenen Modus ein. Default ist
die Desktop-Huelle ("AzA-Empfang"/"AzA-Empfang \xb7 Desktop"), damit Cross-Mode-
Fokus (Chat-Huelle <-> Desktop-Huelle) NICHT passiert.
``exact_titles``: nur exakter Titel-Match (z. B. Chat-Huelle "AzA Chat" ohne
"AzA Chat \xb7 Desktop").
"""
if sys.platform != "win32":
return False
@@ -198,6 +245,7 @@ def _focus_other_empfang_host_window(title_prefixes: tuple[str, ...] = ("AzA-Emp
my_pid = int(os.getpid())
handles: list[int] = []
prefixes = tuple(p for p in title_prefixes if p)
exact = tuple(t for t in exact_titles if t)
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def _enum(hwnd, _lp):
@@ -206,11 +254,11 @@ def _focus_other_empfang_host_window(title_prefixes: tuple[str, ...] = ("AzA-Emp
buf = ctypes.create_unicode_buffer(260)
user32.GetWindowTextW(hwnd, buf, 260)
t = buf.value or ""
if not any(t == p or t.startswith(p) for p in prefixes):
if not _window_title_matches(t, prefixes=prefixes, exact_titles=exact):
return True
pid = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if int(pid.value) != my_pid:
if int(pid.value) != my_pid and _hwnd_process_alive(int(hwnd)):
handles.append(int(hwnd))
return True
@@ -226,6 +274,51 @@ def _focus_other_empfang_host_window(title_prefixes: tuple[str, ...] = ("AzA-Emp
return False
def _resolve_empfang_shell_bundle_path() -> Path | None:
"""AZA_EmpfangShell.exe neben der laufenden EXE (wie Office-Singleton-Start)."""
if (
not getattr(sys, "frozen", False)
and str(os.getenv("AZA_EMPFANG_SHELL_USE_PYTHON", "")).strip() == "1"
):
return None
candidates: list[Path] = []
try:
if getattr(sys, "frozen", False):
exe_dir = Path(sys.executable).resolve().parent
candidates.append(exe_dir / "AZA_EmpfangShell.exe")
candidates.append(exe_dir / "_internal" / "AZA_EmpfangShell.exe")
meip = getattr(sys, "_MEIPASS", "")
if meip:
candidates.append(Path(meip) / "AZA_EmpfangShell.exe")
except Exception:
pass
root = Path(__file__).resolve().parent
candidates.append(root / "AZA_EmpfangShell.exe")
candidates.append(root / "dist" / "AZA_EmpfangShell.exe")
for p in candidates:
try:
if p.is_file():
return p
except Exception:
continue
return None
def _spawn_empfang_chat_shell_process() -> None:
"""Startet die dedizierte Chat-Huelle einmal (AZA_EmpfangShell bevorzugt)."""
chat_url = _build_default_empfang_chat_shell_url()
w_main, h_main = 1180, 820
exe_bundle = _resolve_empfang_shell_bundle_path()
if exe_bundle is not None:
cmd = [str(exe_bundle), chat_url, str(w_main), str(h_main)]
cwd = str(exe_bundle.parent)
else:
script = Path(__file__).resolve()
cmd = [sys.executable, str(script), chat_url, str(w_main), str(h_main)]
cwd = str(script.parent)
subprocess.Popen(cmd, cwd=cwd, **empfang_subprocess_popen_kwargs())
def _focus_office_main_window() -> bool:
"""AzA Office (Hauptfenster) in den Vordergrund — fuer Hüllen-Update-Hinweis."""
return _focus_other_empfang_host_window(("AzA Office",))
@@ -234,11 +327,12 @@ def _focus_office_main_window() -> bool:
def _empfang_shell_icon_path(mode: str | None = None) -> str | None:
"""Windows/pywebview: WinForms laedt Fenstericon aus .ico neben diesem Skript.
Im PyInstaller-Bundle liegt logo.ico bzw. aza_kontakt_panel.ico unter sys._MEIPASS
(bzw. neben sys.executable). Kontakt-Panel nutzt das eigene Pfoten-Icon.
Im PyInstaller-Bundle liegt aza_chat_logo8.ico bzw. aza_kontakt_panel.ico unter
sys._MEIPASS (bzw. neben sys.executable). Kontakt-Panel behaelt sein eigenes Icon;
Desktop-/Chat-Huelle nutzen Logo8.
"""
is_kontakt_panel = mode == "kontakt_panel"
ico_name = "aza_kontakt_panel.ico" if is_kontakt_panel else "logo.ico"
ico_name = "aza_kontakt_panel.ico" if is_kontakt_panel else "aza_chat_logo8.ico"
candidates: list[Path] = []
try:
if getattr(sys, "frozen", False):
@@ -251,6 +345,8 @@ def _empfang_shell_icon_path(mode: str | None = None) -> str | None:
base = Path(__file__).resolve().parent
if is_kontakt_panel:
candidates.append(base / "assets" / ico_name)
else:
candidates.append(base / "assets" / ico_name)
candidates.append(base / ico_name)
for c in candidates:
try:
@@ -351,12 +447,12 @@ def _split_argv_for_shell_mode(argv: list[str]) -> tuple[list[str], str]:
def _window_title_for_mode(mode: str) -> str:
if mode == _MODE_DESKTOP_SHELL:
return "AzA-Empfang \u00b7 Desktop"
return "AzA Chat \u00b7 Desktop"
if mode == _MODE_MINICHAT:
return "AzA MiniChat"
if mode == _MODE_KONTAKT_PANEL:
return "AzA Kontakte"
return "AzA Empfang Chat"
return "AzA Chat"
def _aumid_for_mode(mode: str) -> str:
@@ -663,22 +759,31 @@ class EmpfangWebviewApi:
def focus_empfang_chat_shell(self) -> dict:
"""Kontakt-Panel: bestehende Empfang-Chat-Huelle (anderer Prozess) nach vorne holen.
Reine Wiederverwendung von ``_focus_other_empfang_host_window`` mit dem
Titel-Praefix der Chat-Huelle. Nicht-blockierend (Daemon-Thread), damit
der WebView-Worker des Panels nicht haengt. Keine Chat-/Patientendaten.
Nur exakt ``AzA Chat`` (empfang_chat_shell) — NICHT ``AzA Chat \xb7 Desktop``.
Ist keine lebende Chat-Huelle vorhanden, fordert AzA Office per lokalem IPC
den bewaehrten Office-Starter (Shell-Session + Launch-Token) an.
Der eigentliche Peer-/Thread-Wechsel passiert NICHT hier, sondern in der
Huelle selbst: sie liest den per ``POST /empfang/shell/dm-open`` gesetzten
Shell-Context beim Fokus erneut (bestehender refreshDesktopShellContext-Pfad).
Der Peer-/Thread-Wechsel passiert in der Huelle via Shell-Context
(``POST /empfang/shell/dm-open`` + ``refreshDesktopShellContextFromServer``).
"""
prefixes = (
_window_title_for_mode(_MODE_EMPFANG_CHAT_SHELL),
_window_title_for_mode(_MODE_DESKTOP_SHELL),
)
chat_title = _window_title_for_mode(_MODE_EMPFANG_CHAT_SHELL)
def _work() -> None:
try:
_focus_other_empfang_host_window(prefixes)
from aza_empfang_shell_surface import touch_shell_peer_refresh_signal
touch_shell_peer_refresh_signal(source="focus_shell")
except Exception:
pass
try:
if _focus_other_empfang_host_window(exact_titles=(chat_title,)):
return
except Exception:
pass
try:
from aza_empfang_shell_surface import request_office_open_empfang_chat_shell_ipc
request_office_open_empfang_chat_shell_ipc()
except Exception:
pass
@@ -712,6 +817,89 @@ class EmpfangWebviewApi:
threading.Thread(target=_work, daemon=True).start()
return {"ok": True}
def get_chat_update_hint(self) -> dict:
"""Chat-only: IPC-Hinweis vom Chat-Host (Kontaktpanel-Badge)."""
try:
from desktop_update_check import read_chat_update_hint
hint = read_chat_update_hint()
if hint:
return hint
except Exception:
pass
return {"available": False}
def request_chat_update_dialog(self) -> dict:
"""Kontaktpanel: Chat-Host oeffnet Update-Dialog (kein Installer in WebView)."""
def _work() -> None:
try:
from desktop_update_check import manual_check_for_chat_updates
manual_check_for_chat_updates(parent=None)
except Exception:
try:
from desktop_update_check import request_chat_update_dialog_ipc
request_chat_update_dialog_ipc()
except Exception:
pass
threading.Thread(target=_work, daemon=True).start()
return {"ok": True}
def _inject_kontakt_update_badge(self) -> None:
win = self._window
if win is None or self._mode != _MODE_KONTAKT_PANEL:
return
js = (
"(function(){try{"
"var bar=document.getElementById('aza-chat-update-badge');"
"if(!bar){"
"bar=document.createElement('div');"
"bar.id='aza-chat-update-badge';"
"bar.style.cssText='display:none;align-items:center;justify-content:center;gap:10px;"
"padding:8px 12px;background:#FFF3E0;border-top:1px solid #E8A54B;color:#7A4A00;"
"font-size:13px;position:fixed;left:0;right:0;bottom:0;z-index:9999;';"
"var btn=document.createElement('button');"
"btn.type='button';"
"btn.textContent='Update';"
"btn.style.cssText='border:1px solid #E8A54B;background:#FFE8C8;color:#7A4A00;"
"border-radius:6px;padding:4px 12px;cursor:pointer;font-size:13px;';"
"btn.onclick=function(){try{if(window.pywebview&&window.pywebview.api&&"
"window.pywebview.api.request_chat_update_dialog){"
"window.pywebview.api.request_chat_update_dialog();}}catch(_e){}};"
"var txt=document.createElement('span');txt.id='aza-chat-update-badge-text';"
"bar.appendChild(txt);bar.appendChild(btn);document.body.appendChild(bar);"
"}"
"return bar;}catch(e){return null;}})();"
)
try:
win.evaluate_js(js)
except Exception:
return
try:
from desktop_update_check import read_chat_update_hint
hint = read_chat_update_hint()
if hint and hint.get("available"):
ver = str(hint.get("latest_version") or "").strip()
txt = ("Update " + ver) if ver else "Update"
win.evaluate_js(
"(function(){var b=document.getElementById('aza-chat-update-badge');"
"var t=document.getElementById('aza-chat-update-badge-text');"
"if(!b||!t)return;"
"t.textContent=" + json.dumps(txt) + ";"
"b.style.display='flex';})();"
)
else:
win.evaluate_js(
"try{var b=document.getElementById('aza-chat-update-badge');"
"if(b)b.style.display='none';}catch(_e){}"
)
except Exception:
pass
@staticmethod
def _pinsel_eval_js(win, js_code: str, label: str) -> None:
"""Lokal nur stdout/stderr; keine Clipboard-/Patient-Inhalte loggen."""
@@ -1011,6 +1199,251 @@ class EmpfangWebviewApi:
threading.Thread(target=_do, daemon=True).start()
return {"ok": True, "scheduled": True}
def report_chat_surface_state(self, payload: dict | None = None) -> dict:
"""JS-API: aktiver Chat-Peer fuer Desktop-Popup-Unterdrueckung (keine Texte)."""
data = payload if isinstance(payload, dict) else {}
minimized = False
if sys.platform == "win32":
try:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
my_pid = int(os.getpid())
found_iconic = [False]
@ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
def _cb(hwnd, _lp):
if not user32.IsWindowVisible(hwnd) and not user32.IsIconic(hwnd):
return True
p = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(p))
if int(p.value) != my_pid:
return True
if user32.IsIconic(hwnd):
found_iconic[0] = True
return False
return True
user32.EnumWindows(_cb, 0)
minimized = bool(found_iconic[0])
except Exception:
minimized = False
try:
from aza_empfang_shell_surface import write_shell_surface_state
write_shell_surface_state(
{
"mode": data.get("mode"),
"peer_user_id": data.get("peer_user_id"),
"external_peer_user_id": data.get("external_peer_user_id"),
"document_visible": data.get("document_visible"),
"has_focus": data.get("has_focus"),
"shell_minimized": minimized,
}
)
except Exception:
pass
return {"ok": True}
def consume_peer_refresh_signal(self) -> dict:
"""JS-Polling: Kontakt-Panel hat dm_open gesetzt — Context erneut lesen."""
try:
last = float(getattr(self, "_peer_refresh_last_ts", 0.0) or 0.0)
from aza_empfang_shell_surface import consume_shell_peer_refresh_signal
ts = consume_shell_peer_refresh_signal(last)
if ts > last:
self._peer_refresh_last_ts = ts
return {"pending": True}
except Exception:
pass
return {"pending": False}
def shell_reload_current(self) -> dict:
"""Begrenzter Reload bei leerer/haengender WebView (keine Endlosschleife)."""
win = self._window
if win is None:
return {"ok": False, "reason": "no_window"}
attempts = int(getattr(self, "_shell_reload_attempts", 0) or 0)
if attempts >= 2:
return {"ok": False, "reason": "limit"}
self._shell_reload_attempts = attempts + 1
target = str(getattr(self, "_shell_reload_target_url", "") or "").strip()
if not target:
return {"ok": False, "reason": "no_target"}
def _do() -> None:
try:
from aza_empfang_shell_surface import append_empfang_shell_debug_log
append_empfang_shell_debug_log(
f"action=reload attempt={self._shell_reload_attempts} mode={self._mode}"
)
_webview_navigate(win, target)
except Exception as exc:
try:
from aza_empfang_shell_surface import append_empfang_shell_debug_log
append_empfang_shell_debug_log(
f"action=reload_failed err={type(exc).__name__}"
)
except Exception:
pass
threading.Thread(target=_do, daemon=True).start()
return {"ok": True, "scheduled": True}
def _shell_debug(self, msg: str) -> None:
try:
from aza_empfang_shell_surface import append_empfang_shell_debug_log
append_empfang_shell_debug_log(msg)
except Exception:
pass
def _schedule_shell_health_checks(self, start_url: str) -> None:
"""Nach Start: DOM-Bereitschaft pruefen, bei Blank begrenzt neu laden."""
win = self._window
if win is None:
return
self._shell_reload_target_url = _shell_stable_reload_url(start_url, self._mode)
self._shell_reload_attempts = 0
self._shell_health_checks_done = 0
self._shell_debug(
f"action=health_watch mode={self._mode} url_path={_shell_redact_url_for_log(start_url)}"
)
def _tick(delay_s: float) -> None:
def _run() -> None:
if win is None:
return
done = int(getattr(self, "_shell_health_checks_done", 0) or 0)
if done >= 3:
return
self._shell_health_checks_done = done + 1
probe_js = (
"(function(){try{"
"var a=document.getElementById('app-layout');"
"var l=document.getElementById('login-overlay');"
"var b=document.getElementById('empfang-shell-boot');"
"if(a||l)return 'dom_ok';"
"if(b)return 'boot_only';"
"return 'blank';"
"}catch(e){return 'js_err';}})();"
)
state = "unknown"
try:
state = str(win.evaluate_js(probe_js) or "").strip().lower()
except Exception as exc:
state = f"eval_err:{type(exc).__name__}"
bridge = "no"
try:
bridge = str(
win.evaluate_js(
"(function(){try{return (window.pywebview&&window.pywebview.api)?'yes':'no';}catch(e){return 'no';}})();"
)
or "no"
).strip().lower()
except Exception:
bridge = "eval_err"
self._shell_debug(
f"action=health_probe check={done + 1} dom={state} bridge={bridge}"
)
if state in ("blank", "boot_only", "js_err") or state.startswith("eval_err"):
rel = self.shell_reload_current()
if not rel.get("ok"):
self._shell_debug(
f"action=health_reload_skipped reason={rel.get('reason', '?')}"
)
try:
self._inject_kontakt_update_badge()
except Exception:
pass
threading.Timer(delay_s, _run).start()
for d in (2.5, 6.0, 12.0):
_tick(d)
def _shell_origin_from_env_or_url(start_url: str) -> str:
for key in ("AZA_EMPFANG_WEB_BASE", "AZA_EMPFANG_CHAT_SHELL_URL"):
val = (os.environ.get(key) or "").strip()
if not val:
continue
try:
p = urlparse(val)
if p.scheme and p.netloc:
return f"{p.scheme}://{p.netloc}"
except Exception:
pass
proxy = _resolve_test_proxy_base()
if proxy:
return proxy.rstrip("/")
u = str(start_url or "").strip()
try:
p = urlparse(u)
if p.scheme and p.netloc:
return f"{p.scheme}://{p.netloc}"
except Exception:
pass
try:
p2 = urlparse(_EMPFANG_CHAT_SHELL_DEFAULT_BASE)
if p2.scheme and p2.netloc:
return f"{p2.scheme}://{p2.netloc}"
except Exception:
pass
return "https://empfang.aza-medwork.ch"
def _webview_navigate(win: object, target: str) -> None:
url = str(target or "").strip()
if not url:
return
for name in ("load_url", "loadUrl"):
fn = getattr(win, name, None)
if callable(fn):
fn(url)
return
escaped = json.dumps(url)
win.evaluate_js(f"window.location.assign({escaped});")
def _shell_redact_url_for_log(url: str) -> str:
try:
p = urlparse(str(url or "").strip())
if p.scheme and p.netloc:
return f"{p.scheme}://{p.netloc}{p.path or '/'}"
except Exception:
pass
return "/empfang/"
def _shell_stable_reload_url(start_url: str, mode: str) -> str:
"""Stabile Empfang-URL nach Launch-Redirect (Cookie-Session bleibt erhalten)."""
origin = _shell_origin_from_env_or_url(start_url)
m = (mode or "").strip().lower()
if m == _MODE_EMPFANG_CHAT_SHELL:
base = (os.environ.get("AZA_EMPFANG_CHAT_SHELL_URL") or "").strip()
if not base:
base = f"{origin.rstrip('/')}/empfang/"
url = _append_query_marker(base, "empfang_chat_shell", "1")
return _append_query_marker(url, "shell_source", "empfang_chat_shell")
if m == _MODE_KONTAKT_PANEL:
base = (os.environ.get("AZA_EMPFANG_CHAT_SHELL_URL") or "").strip()
if not base:
base = f"{origin.rstrip('/')}/empfang/"
url = _append_query_marker(base, "kontakt_panel", "1")
return _append_query_marker(url, "shell_source", "kontakt_panel")
if m == _MODE_MINICHAT:
return f"{origin.rstrip('/')}/empfang/?minichat=1"
return _append_query_marker(
_append_query_marker(f"{origin.rstrip('/')}/empfang/", "desktop_shell", "1"),
"shell_source",
"aza_desktop",
)
def _append_query_marker(url: str, key: str, value: str) -> str:
"""Setzt/ersetzt einen Query-Parameter, ohne andere Parameter zu zerstoeren."""
@@ -1027,6 +1460,43 @@ def _append_query_marker(url: str, key: str, value: str) -> str:
_EMPFANG_CHAT_SHELL_DEFAULT_BASE = "https://empfang.aza-medwork.ch/empfang/"
def _doku_prompt_test_active() -> bool:
return os.environ.get("AZA_DOKU_PROMPT_TEST", "").strip().lower() in ("1", "true", "yes")
def _resolve_test_proxy_base() -> str | None:
if not _doku_prompt_test_active():
return None
try:
from aza_empfang_test_html_proxy import test_proxy_base_url
return test_proxy_base_url()
except Exception:
return None
def _empfang_shell_base_for_build() -> str:
"""Basis-URL fuer Shell/Kontakt-Panel — im Testbuild lokaler HTML-Proxy."""
proxy = _resolve_test_proxy_base()
if proxy:
return proxy.rstrip("/")
base = (os.environ.get("AZA_EMPFANG_CHAT_SHELL_URL") or "").strip()
if base:
try:
p = urlparse(base)
if p.scheme and p.netloc:
return f"{p.scheme}://{p.netloc}".rstrip("/")
except Exception:
pass
try:
p2 = urlparse(_EMPFANG_CHAT_SHELL_DEFAULT_BASE)
if p2.scheme and p2.netloc:
return f"{p2.scheme}://{p2.netloc}".rstrip("/")
except Exception:
pass
return "https://empfang.aza-medwork.ch"
def _build_default_empfang_chat_shell_url() -> str:
"""Standard-URL der separaten Empfang-Chat-Huelle (kein Arzt-Desktop).
@@ -1042,7 +1512,11 @@ def _build_default_empfang_chat_shell_url() -> str:
"""
base = (os.environ.get("AZA_EMPFANG_CHAT_SHELL_URL") or "").strip()
if not base:
base = _EMPFANG_CHAT_SHELL_DEFAULT_BASE
proxy = _resolve_test_proxy_base()
if proxy:
base = f"{proxy.rstrip('/')}/empfang/"
else:
base = _EMPFANG_CHAT_SHELL_DEFAULT_BASE
# Sicherstellen, dass /empfang/ Pfad enthalten ist
try:
p = urlparse(base)
@@ -1069,7 +1543,11 @@ def _build_default_kontakt_panel_url() -> str:
"""
base = (os.environ.get("AZA_EMPFANG_CHAT_SHELL_URL") or "").strip()
if not base:
base = _EMPFANG_CHAT_SHELL_DEFAULT_BASE
proxy = _resolve_test_proxy_base()
if proxy:
base = f"{proxy.rstrip('/')}/empfang/"
else:
base = _EMPFANG_CHAT_SHELL_DEFAULT_BASE
try:
p = urlparse(base)
if not (p.path or "").rstrip("/").endswith("/empfang"):
@@ -1264,13 +1742,23 @@ def main(argv: list[str] | None = None) -> int:
_ico = _empfang_shell_icon_path(mode)
if _ico:
start_kw["icon"] = _ico
def _on_webview_ready() -> None:
try:
api._shell_debug(
f"action=webview_ready mode={mode} url_path={_shell_redact_url_for_log(url)}"
)
api._schedule_shell_health_checks(url)
except Exception:
pass
try:
win = webview.create_window(
window_title, url, width=w, height=h, js_api=api,
)
api.bind_window(win)
try:
webview.start(**start_kw)
webview.start(func=_on_webview_ready, **start_kw)
finally:
_empfang_quiet_pyi_temp_teardown()
return 0