1413 lines
55 KiB
Python
1413 lines
55 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""Gemeinsame Empfang-/Chat-Desktop-Logik (Singleton Huelle + Kontaktpanel)."""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
import ctypes
|
||
|
|
from ctypes import wintypes
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Optional, Tuple
|
||
|
|
from urllib.parse import quote
|
||
|
|
|
||
|
|
import requests
|
||
|
|
import tkinter as tk
|
||
|
|
from tkinter import messagebox
|
||
|
|
import unicodedata
|
||
|
|
|
||
|
|
|
||
|
|
def _empfang_identity_key(label: str) -> str:
|
||
|
|
t = (label or "").strip().lower()
|
||
|
|
t = unicodedata.normalize("NFKD", t)
|
||
|
|
return "".join(ch for ch in t if unicodedata.combining(ch) == 0)
|
||
|
|
|
||
|
|
|
||
|
|
def init_empfang_desktop_core_state(host) -> None:
|
||
|
|
host._empfang_webview_proc = None
|
||
|
|
host._empfang_webview_last_child_pid = None
|
||
|
|
host._kontakt_panel_proc = None
|
||
|
|
host._kontakt_panel_last_child_pid = None
|
||
|
|
host._kontakt_panel_inflight_until = 0.0
|
||
|
|
host._kontakt_panel_launch_ts = 0.0
|
||
|
|
host._kontakt_panel_launch_lock = threading.Lock()
|
||
|
|
host._aza_tracked_external_pids = set()
|
||
|
|
host._empfang_webview_launch_lock = threading.Lock()
|
||
|
|
host._empfang_webview_launch_inflight_until = 0.0
|
||
|
|
host._empfang_webview_launch_ts = 0.0
|
||
|
|
|
||
|
|
|
||
|
|
class EmpfangDesktopCoreMixin:
|
||
|
|
_EMPFANG_SHELL_TITLE_PREFIXES: Tuple[str, ...] = (
|
||
|
|
"AzA Chat",
|
||
|
|
"AzA-Empfang",
|
||
|
|
"AzA Empfang Chat",
|
||
|
|
)
|
||
|
|
_KONTAKT_PANEL_TITLE_PREFIXES: Tuple[str, ...] = ("AzA Kontakte",)
|
||
|
|
|
||
|
|
def _win32_top_level_usable(self, hwnd: int) -> bool:
|
||
|
|
"""Sichtbar oder minimiert (nicht versteckt) — fuer Singleton-Fokus."""
|
||
|
|
if sys.platform != "win32" or not int(hwnd or 0):
|
||
|
|
return False
|
||
|
|
try:
|
||
|
|
user32 = ctypes.windll.user32
|
||
|
|
if not user32.IsWindow(hwnd):
|
||
|
|
return False
|
||
|
|
return bool(user32.IsWindowVisible(hwnd) or user32.IsIconic(hwnd))
|
||
|
|
except Exception:
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _title_matches_prefixes(self, title: str, prefixes: Tuple[str, ...]) -> bool:
|
||
|
|
t = title or ""
|
||
|
|
return any(t == p or t.startswith(p) for p in prefixes if p)
|
||
|
|
|
||
|
|
def _singleton_inflight_treat_as_alive(
|
||
|
|
self,
|
||
|
|
*,
|
||
|
|
inflight_until: float,
|
||
|
|
launch_ts: float,
|
||
|
|
proc,
|
||
|
|
last_child_pid: int,
|
||
|
|
title_prefixes: Tuple[str, ...],
|
||
|
|
empty_grace_s: float = 18.0,
|
||
|
|
) -> bool:
|
||
|
|
"""Inflight nur waehrend Fetch/Popen — nicht nach geschlossenem Fenster blockieren."""
|
||
|
|
now = time.time()
|
||
|
|
if now >= float(inflight_until or 0.0):
|
||
|
|
return False
|
||
|
|
try:
|
||
|
|
if proc is not None and proc.poll() is None:
|
||
|
|
return True
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
lip = int(last_child_pid or 0)
|
||
|
|
if lip and self._empfang_win_child_pid_alive(lip):
|
||
|
|
return True
|
||
|
|
try:
|
||
|
|
if self._enum_visible_window_hwnds_for_prefixes(title_prefixes):
|
||
|
|
return True
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
if proc is None and not lip and (now - float(launch_ts or 0.0)) < float(empty_grace_s or 18.0):
|
||
|
|
return True
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _tracked_child_hwnd_for_pid_win(
|
||
|
|
self, target_pid: int, title_prefixes: Tuple[str, ...]
|
||
|
|
) -> int:
|
||
|
|
"""Top-Level-Fenster eines bekannten Kindprozesses (nur Titel-Praefixe)."""
|
||
|
|
if sys.platform != "win32" or int(target_pid or 0) <= 0:
|
||
|
|
return 0
|
||
|
|
try:
|
||
|
|
user32 = ctypes.windll.user32
|
||
|
|
found: list[int] = []
|
||
|
|
|
||
|
|
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
|
||
|
|
def _cb(hwnd, _lp):
|
||
|
|
if not self._win32_top_level_usable(hwnd):
|
||
|
|
return True
|
||
|
|
p = wintypes.DWORD()
|
||
|
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(p))
|
||
|
|
if int(p.value) != int(target_pid):
|
||
|
|
return True
|
||
|
|
buf = ctypes.create_unicode_buffer(320)
|
||
|
|
user32.GetWindowTextW(hwnd, buf, 320)
|
||
|
|
t = buf.value or ""
|
||
|
|
if self._title_matches_prefixes(t, title_prefixes):
|
||
|
|
found.append(int(hwnd))
|
||
|
|
return False
|
||
|
|
return True
|
||
|
|
|
||
|
|
user32.EnumWindows(_cb, 0)
|
||
|
|
return int(found[0]) if found else 0
|
||
|
|
except Exception:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
def _all_tracked_child_hwnds_for_pid_win(
|
||
|
|
self, target_pid: int, title_prefixes: Tuple[str, ...]
|
||
|
|
) -> list[int]:
|
||
|
|
"""Alle nutzbaren Top-Level-Fenster eines Prozesses (Titel-Praefixe)."""
|
||
|
|
if sys.platform != "win32" or int(target_pid or 0) <= 0:
|
||
|
|
return []
|
||
|
|
try:
|
||
|
|
user32 = ctypes.windll.user32
|
||
|
|
found: list[int] = []
|
||
|
|
|
||
|
|
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
|
||
|
|
def _cb(hwnd, _lp):
|
||
|
|
if not self._win32_top_level_usable(hwnd):
|
||
|
|
return True
|
||
|
|
p = wintypes.DWORD()
|
||
|
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(p))
|
||
|
|
if int(p.value) != int(target_pid):
|
||
|
|
return True
|
||
|
|
buf = ctypes.create_unicode_buffer(320)
|
||
|
|
user32.GetWindowTextW(hwnd, buf, 320)
|
||
|
|
t = buf.value or ""
|
||
|
|
if self._title_matches_prefixes(t, title_prefixes):
|
||
|
|
found.append(int(hwnd))
|
||
|
|
return True
|
||
|
|
|
||
|
|
user32.EnumWindows(_cb, 0)
|
||
|
|
out: list[int] = []
|
||
|
|
seen: set[int] = set()
|
||
|
|
for h in found:
|
||
|
|
if h not in seen:
|
||
|
|
seen.add(h)
|
||
|
|
out.append(h)
|
||
|
|
return out
|
||
|
|
except Exception:
|
||
|
|
return []
|
||
|
|
|
||
|
|
def _post_wm_close_to_hwnd(self, hwnd: int) -> None:
|
||
|
|
if sys.platform != "win32" or not int(hwnd or 0):
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
WM_CLOSE = 0x0010
|
||
|
|
ctypes.windll.user32.PostMessageW(int(hwnd), WM_CLOSE, 0, 0)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _aza_track_external_pid(self, pid: int) -> None:
|
||
|
|
try:
|
||
|
|
p = int(pid or 0)
|
||
|
|
if p > 0 and p != int(os.getpid()):
|
||
|
|
self._aza_tracked_external_pids.add(p)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _win32_bring_hwnd_to_front(self, hwnd: int) -> None:
|
||
|
|
if sys.platform != "win32" or not int(hwnd or 0):
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
user32 = ctypes.windll.user32
|
||
|
|
SW_RESTORE = 9
|
||
|
|
user32.ShowWindow(int(hwnd), SW_RESTORE)
|
||
|
|
user32.SetForegroundWindow(int(hwnd))
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _enum_visible_window_hwnds_for_prefixes(
|
||
|
|
self, title_prefixes: Tuple[str, ...]
|
||
|
|
) -> list[tuple[int, int]]:
|
||
|
|
"""(hwnd, pid) aller sichtbaren Top-Level-Fenster mit Titel-Praefix."""
|
||
|
|
if sys.platform != "win32":
|
||
|
|
return []
|
||
|
|
try:
|
||
|
|
user32 = ctypes.windll.user32
|
||
|
|
found: list[tuple[int, int]] = []
|
||
|
|
prefixes = tuple(p for p in title_prefixes if p)
|
||
|
|
|
||
|
|
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
|
||
|
|
def _cb(hwnd, _lp):
|
||
|
|
if not self._win32_top_level_usable(hwnd):
|
||
|
|
return True
|
||
|
|
buf = ctypes.create_unicode_buffer(320)
|
||
|
|
user32.GetWindowTextW(hwnd, buf, 320)
|
||
|
|
t = buf.value or ""
|
||
|
|
if not self._title_matches_prefixes(t, prefixes):
|
||
|
|
return True
|
||
|
|
p = wintypes.DWORD()
|
||
|
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(p))
|
||
|
|
found.append((int(hwnd), int(p.value)))
|
||
|
|
return True
|
||
|
|
|
||
|
|
user32.EnumWindows(_cb, 0)
|
||
|
|
return found
|
||
|
|
except Exception:
|
||
|
|
return []
|
||
|
|
|
||
|
|
def _empfang_shell_refresh_tracked_pid_from_hwnd(self) -> bool:
|
||
|
|
matches = self._enum_visible_window_hwnds_for_prefixes(
|
||
|
|
self._EMPFANG_SHELL_TITLE_PREFIXES
|
||
|
|
)
|
||
|
|
if not matches:
|
||
|
|
return False
|
||
|
|
prefer = 0
|
||
|
|
try:
|
||
|
|
pop = getattr(self, "_empfang_webview_proc", None)
|
||
|
|
if pop is not None and pop.poll() is None:
|
||
|
|
prefer = int(pop.pid)
|
||
|
|
except Exception:
|
||
|
|
prefer = 0
|
||
|
|
lip = int(getattr(self, "_empfang_webview_last_child_pid", 0) or 0)
|
||
|
|
pid_pick = 0
|
||
|
|
for _h, pid in matches:
|
||
|
|
if prefer and pid == prefer:
|
||
|
|
pid_pick = pid
|
||
|
|
break
|
||
|
|
if lip and pid == lip:
|
||
|
|
pid_pick = pid
|
||
|
|
break
|
||
|
|
if not pid_pick:
|
||
|
|
pid_pick = int(matches[0][1])
|
||
|
|
if pid_pick:
|
||
|
|
self._empfang_webview_last_child_pid = pid_pick
|
||
|
|
self._aza_track_external_pid(pid_pick)
|
||
|
|
return True
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _kontakt_panel_refresh_tracked_pid_from_hwnd(self) -> bool:
|
||
|
|
matches = self._enum_visible_window_hwnds_for_prefixes(
|
||
|
|
self._KONTAKT_PANEL_TITLE_PREFIXES
|
||
|
|
)
|
||
|
|
if not matches:
|
||
|
|
return False
|
||
|
|
prefer = 0
|
||
|
|
try:
|
||
|
|
pop = getattr(self, "_kontakt_panel_proc", None)
|
||
|
|
if pop is not None and pop.poll() is None:
|
||
|
|
prefer = int(pop.pid)
|
||
|
|
except Exception:
|
||
|
|
prefer = 0
|
||
|
|
lip = int(getattr(self, "_kontakt_panel_last_child_pid", 0) or 0)
|
||
|
|
pid_pick = 0
|
||
|
|
for _h, pid in matches:
|
||
|
|
if prefer and pid == prefer:
|
||
|
|
pid_pick = pid
|
||
|
|
break
|
||
|
|
if lip and pid == lip:
|
||
|
|
pid_pick = pid
|
||
|
|
break
|
||
|
|
if not pid_pick:
|
||
|
|
pid_pick = int(matches[0][1])
|
||
|
|
if pid_pick:
|
||
|
|
self._kontakt_panel_last_child_pid = pid_pick
|
||
|
|
self._aza_track_external_pid(pid_pick)
|
||
|
|
return True
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _kontakt_panel_subprocess_alive(self) -> bool:
|
||
|
|
try:
|
||
|
|
proc = getattr(self, "_kontakt_panel_proc", None)
|
||
|
|
if proc is not None and proc.poll() is None:
|
||
|
|
try:
|
||
|
|
self._aza_track_external_pid(int(proc.pid))
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
return True
|
||
|
|
if proc is not None and proc.poll() is not None:
|
||
|
|
# PyInstaller-Bootloader beendet — Kind-PID per HWND nachziehen.
|
||
|
|
self._kontakt_panel_proc = None
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
self._kontakt_panel_refresh_tracked_pid_from_hwnd()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
lip = int(getattr(self, "_kontakt_panel_last_child_pid", 0) or 0)
|
||
|
|
if lip and self._empfang_win_child_pid_alive(lip):
|
||
|
|
return True
|
||
|
|
infl = float(getattr(self, "_kontakt_panel_inflight_until", 0.0) or 0.0)
|
||
|
|
if infl and time.time() < infl:
|
||
|
|
if self._singleton_inflight_treat_as_alive(
|
||
|
|
inflight_until=infl,
|
||
|
|
launch_ts=float(getattr(self, "_kontakt_panel_launch_ts", 0.0) or 0.0),
|
||
|
|
proc=getattr(self, "_kontakt_panel_proc", None),
|
||
|
|
last_child_pid=lip,
|
||
|
|
title_prefixes=self._KONTAKT_PANEL_TITLE_PREFIXES,
|
||
|
|
empty_grace_s=6.0,
|
||
|
|
):
|
||
|
|
return True
|
||
|
|
self._kontakt_panel_inflight_until = 0.0
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _kontakt_panel_try_bring_to_front(self, delay_ms: int = 200) -> bool:
|
||
|
|
if sys.platform != "win32":
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _run():
|
||
|
|
try:
|
||
|
|
self._kontakt_panel_refresh_tracked_pid_from_hwnd()
|
||
|
|
lip = int(getattr(self, "_kontakt_panel_last_child_pid", 0) or 0)
|
||
|
|
hwnd = 0
|
||
|
|
if lip:
|
||
|
|
hwnd = self._tracked_child_hwnd_for_pid_win(
|
||
|
|
lip, self._KONTAKT_PANEL_TITLE_PREFIXES
|
||
|
|
)
|
||
|
|
if not hwnd:
|
||
|
|
for h, _p in self._enum_visible_window_hwnds_for_prefixes(
|
||
|
|
self._KONTAKT_PANEL_TITLE_PREFIXES
|
||
|
|
):
|
||
|
|
hwnd = h
|
||
|
|
break
|
||
|
|
if hwnd:
|
||
|
|
self._win32_bring_hwnd_to_front(hwnd)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
try:
|
||
|
|
self.after(max(0, int(delay_ms)), _run)
|
||
|
|
return True
|
||
|
|
except Exception:
|
||
|
|
return False
|
||
|
|
def _fetch_empfang_shell_session_dict(self) -> Tuple[Optional[dict], Optional[str]]:
|
||
|
|
"""POST /empfang/shell/session ohne UI.
|
||
|
|
|
||
|
|
Rueckgabe: (payload, None) oder (None, kurze deutsch Fehlertext).
|
||
|
|
Kein shell_token im Fehlertext.
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
self._empfang_self_user_id_resolve_now()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
bu = self.get_backend_url()
|
||
|
|
except Exception as e:
|
||
|
|
return None, f"Backend nicht erreichbar: {e}"
|
||
|
|
|
||
|
|
hdrs = dict(self._empfang_headers())
|
||
|
|
if not hdrs.get("X-Practice-Id"):
|
||
|
|
return None, (
|
||
|
|
"practice_id fehlt im Profil.\nBitte Praxis-Verknuepfung / Empfang-Provisioning."
|
||
|
|
)
|
||
|
|
if not hdrs.get("X-AzA-Empfang-User-Id"):
|
||
|
|
return None, (
|
||
|
|
"Keine serverseitige Empfang-user_id.\nBitte erst »An Empfang senden« oeffnen, "
|
||
|
|
"Benutzer laden, dann erneut versuchen."
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
r = requests.post(
|
||
|
|
f"{bu}/empfang/shell/session",
|
||
|
|
headers=hdrs,
|
||
|
|
json={},
|
||
|
|
timeout=20,
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
return None, f"Netzwerkfehler: {e}"
|
||
|
|
|
||
|
|
if r.status_code != 200:
|
||
|
|
try:
|
||
|
|
err = r.json()
|
||
|
|
detail = err.get("detail") if isinstance(err, dict) else None
|
||
|
|
if isinstance(detail, list) and detail:
|
||
|
|
detail = str(detail[0])
|
||
|
|
if not isinstance(detail, str):
|
||
|
|
detail = None
|
||
|
|
except Exception:
|
||
|
|
detail = None
|
||
|
|
if not detail:
|
||
|
|
detail = ((r.text or "").strip()[:260] if (r.text or "").strip()
|
||
|
|
else None) or f"HTTP {r.status_code}"
|
||
|
|
return None, f"Server ({r.status_code}): {detail}"
|
||
|
|
|
||
|
|
try:
|
||
|
|
return r.json(), None
|
||
|
|
except Exception:
|
||
|
|
return None, "Ungueltige JSON-Antwort."
|
||
|
|
|
||
|
|
def _empfang_shell_diag_log(self, **fields) -> None:
|
||
|
|
"""Kurzdiagnose fuer Empfang-Web-Shell-Start (keine Tokens/Passwoerter)."""
|
||
|
|
try:
|
||
|
|
parts = [f"[EMPFANG_SHELL_START] source={fields.get('source', '?')}"]
|
||
|
|
order = (
|
||
|
|
"action", "runner", "url_path", "has_token", "child_pid", "popen_pid",
|
||
|
|
"existing_proc_alive", "hwnd_found", "skip_reason", "exe_hint",
|
||
|
|
)
|
||
|
|
for k in order:
|
||
|
|
if k in fields and fields[k] is not None:
|
||
|
|
parts.append(f"{k}={fields[k]}")
|
||
|
|
self._debug_log(" ".join(parts))
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _empfang_win_child_pid_alive(self, pid: int) -> bool:
|
||
|
|
if sys.platform != "win32" or int(pid or 0) <= 0:
|
||
|
|
return False
|
||
|
|
try:
|
||
|
|
kernel32 = ctypes.windll.kernel32
|
||
|
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||
|
|
h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, int(pid))
|
||
|
|
if not h:
|
||
|
|
return False
|
||
|
|
kernel32.CloseHandle(h)
|
||
|
|
return True
|
||
|
|
except Exception:
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _empfang_webview_hwnd_for_pid_win(self, target_pid: int) -> int:
|
||
|
|
"""Sichtbares Top-Level-Fenster des Prozesses mit AzA-Empfang-Titel (kein globales FindWindow)."""
|
||
|
|
if sys.platform != "win32" or int(target_pid or 0) <= 0:
|
||
|
|
return 0
|
||
|
|
try:
|
||
|
|
user32 = ctypes.windll.user32
|
||
|
|
found: list[int] = []
|
||
|
|
|
||
|
|
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
|
||
|
|
def _cb(hwnd, _lp):
|
||
|
|
if not self._win32_top_level_usable(hwnd):
|
||
|
|
return True
|
||
|
|
p = wintypes.DWORD()
|
||
|
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(p))
|
||
|
|
if int(p.value) != int(target_pid):
|
||
|
|
return True
|
||
|
|
buf = ctypes.create_unicode_buffer(320)
|
||
|
|
user32.GetWindowTextW(hwnd, buf, 320)
|
||
|
|
t = buf.value or ""
|
||
|
|
if self._title_matches_prefixes(t, self._EMPFANG_SHELL_TITLE_PREFIXES):
|
||
|
|
found.append(int(hwnd))
|
||
|
|
return False
|
||
|
|
return True
|
||
|
|
|
||
|
|
user32.EnumWindows(_cb, 0)
|
||
|
|
return int(found[0]) if found else 0
|
||
|
|
except Exception:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
def _empfang_webview_resolve_shell_hwnd(self) -> int:
|
||
|
|
"""HWND nur fuer unseren bekannten Shell-Kindprozess (Popen oder gespeicherte PID)."""
|
||
|
|
self._empfang_webview_prune_dead_proc()
|
||
|
|
pid_cand = 0
|
||
|
|
p = getattr(self, "_empfang_webview_proc", None)
|
||
|
|
if p is not None:
|
||
|
|
try:
|
||
|
|
if p.poll() is None:
|
||
|
|
pid_cand = int(p.pid)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
if not pid_cand:
|
||
|
|
lip = int(getattr(self, "_empfang_webview_last_child_pid", 0) or 0)
|
||
|
|
if lip and self._empfang_win_child_pid_alive(lip):
|
||
|
|
pid_cand = lip
|
||
|
|
if pid_cand:
|
||
|
|
return self._empfang_webview_hwnd_for_pid_win(pid_cand)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
def _empfang_webview_prune_dead_proc(self) -> None:
|
||
|
|
p = getattr(self, "_empfang_webview_proc", None)
|
||
|
|
if p is None:
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
if p.poll() is not None:
|
||
|
|
try:
|
||
|
|
self._empfang_shell_refresh_tracked_pid_from_hwnd()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
lip = int(getattr(self, "_empfang_webview_last_child_pid", 0) or 0)
|
||
|
|
if lip and self._empfang_win_child_pid_alive(lip):
|
||
|
|
self._empfang_webview_proc = None
|
||
|
|
return
|
||
|
|
self._empfang_webview_proc = None
|
||
|
|
try:
|
||
|
|
dead_pid = int(p.pid)
|
||
|
|
if dead_pid and int(
|
||
|
|
getattr(self, "_empfang_webview_last_child_pid", 0) or 0
|
||
|
|
) == dead_pid:
|
||
|
|
self._empfang_webview_last_child_pid = None
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
except Exception:
|
||
|
|
self._empfang_webview_proc = None
|
||
|
|
|
||
|
|
def _empfang_webview_subprocess_alive(self) -> bool:
|
||
|
|
self._empfang_webview_prune_dead_proc()
|
||
|
|
p = getattr(self, "_empfang_webview_proc", None)
|
||
|
|
if p is not None:
|
||
|
|
try:
|
||
|
|
if p.poll() is None:
|
||
|
|
self._aza_track_external_pid(int(p.pid))
|
||
|
|
return True
|
||
|
|
except Exception:
|
||
|
|
self._empfang_webview_proc = None
|
||
|
|
try:
|
||
|
|
self._empfang_shell_refresh_tracked_pid_from_hwnd()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
lip = int(getattr(self, "_empfang_webview_last_child_pid", 0) or 0)
|
||
|
|
if lip and self._empfang_win_child_pid_alive(lip):
|
||
|
|
return True
|
||
|
|
infl = float(getattr(self, "_empfang_webview_launch_inflight_until", 0) or 0)
|
||
|
|
if infl and time.time() < infl:
|
||
|
|
if self._singleton_inflight_treat_as_alive(
|
||
|
|
inflight_until=infl,
|
||
|
|
launch_ts=float(getattr(self, "_empfang_webview_launch_ts", 0.0) or 0.0),
|
||
|
|
proc=getattr(self, "_empfang_webview_proc", None),
|
||
|
|
last_child_pid=lip,
|
||
|
|
title_prefixes=self._EMPFANG_SHELL_TITLE_PREFIXES,
|
||
|
|
):
|
||
|
|
return True
|
||
|
|
self._empfang_webview_launch_inflight_until = 0.0
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _empfang_webview_try_bring_to_front(self, delay_ms: int = 950) -> bool:
|
||
|
|
"""Vordergrund erst verzoegert anfordern (Main-Thread), kein sofortiges Win32 nach Popen."""
|
||
|
|
|
||
|
|
def _run():
|
||
|
|
try:
|
||
|
|
hwnd = self._empfang_webview_resolve_shell_hwnd()
|
||
|
|
if not hwnd:
|
||
|
|
lip = int(getattr(self, "_empfang_webview_last_child_pid", 0) or 0)
|
||
|
|
for h, pid in self._enum_visible_window_hwnds_for_prefixes(
|
||
|
|
self._EMPFANG_SHELL_TITLE_PREFIXES
|
||
|
|
):
|
||
|
|
if not lip or pid == lip:
|
||
|
|
hwnd = h
|
||
|
|
break
|
||
|
|
if not hwnd:
|
||
|
|
return
|
||
|
|
user32 = ctypes.windll.user32
|
||
|
|
SW_RESTORE = 9
|
||
|
|
user32.ShowWindow(hwnd, SW_RESTORE)
|
||
|
|
user32.SetForegroundWindow(hwnd)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
if sys.platform != "win32":
|
||
|
|
return False
|
||
|
|
try:
|
||
|
|
self.after(max(0, int(delay_ms)), _run)
|
||
|
|
return True
|
||
|
|
except Exception:
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _empfang_webview_schedule_focus_retries(
|
||
|
|
self, delays_ms: Tuple[int, ...] = (0, 300, 900)
|
||
|
|
) -> None:
|
||
|
|
"""Mehrere zeitversetzte Win32-Vordergrund-Versuche fuer die Empfang-WebView."""
|
||
|
|
if sys.platform != "win32":
|
||
|
|
return
|
||
|
|
for d in delays_ms:
|
||
|
|
try:
|
||
|
|
self._empfang_webview_try_bring_to_front(delay_ms=int(d))
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _prepare_empfang_prefs_for_webview(self) -> None:
|
||
|
|
"""Schreibt KG-/Patient-Kontext in empfang_prefs (ohne Chattexte)."""
|
||
|
|
prefs = self._autotext_data.setdefault("empfang_prefs", {})
|
||
|
|
extracted = self._extract_kg_sections()
|
||
|
|
prefill = getattr(self, "_empfang_prefill", None)
|
||
|
|
self._empfang_prefill = None
|
||
|
|
if prefill:
|
||
|
|
doc_type = prefill.get("doc_type", "")
|
||
|
|
doc_text = prefill.get("text", "")
|
||
|
|
if doc_type == "Kostengutsprache":
|
||
|
|
extracted["therapieplan"] = doc_text
|
||
|
|
pat = ""
|
||
|
|
kg = self.txt_output.get("1.0", "end").strip()
|
||
|
|
for line in kg.splitlines()[:50]:
|
||
|
|
raw = line.strip()
|
||
|
|
if not raw:
|
||
|
|
continue
|
||
|
|
low = raw.lower()
|
||
|
|
if low.startswith("patient") and ":" in raw:
|
||
|
|
cand = raw.split(":", 1)[1].strip()
|
||
|
|
if cand:
|
||
|
|
pat = cand
|
||
|
|
break
|
||
|
|
m = re.match(r"(?i)^patient\s*[:\.]?\s*(.+)$", raw)
|
||
|
|
if m:
|
||
|
|
cand = m.group(1).strip()
|
||
|
|
if cand:
|
||
|
|
pat = cand
|
||
|
|
break
|
||
|
|
if pat:
|
||
|
|
prefs["last_patient"] = pat
|
||
|
|
try:
|
||
|
|
self._empfang_shell_section_snapshot = {
|
||
|
|
"therapieplan": (extracted.get("therapieplan") or "").strip(),
|
||
|
|
"procedere": (extracted.get("procedere") or "").strip(),
|
||
|
|
"medikamente": (extracted.get("medikamente") or "").strip(),
|
||
|
|
}
|
||
|
|
except Exception:
|
||
|
|
self._empfang_shell_section_snapshot = None
|
||
|
|
self._autotext_data["empfang_prefs"] = prefs
|
||
|
|
try:
|
||
|
|
save_autotext(self._autotext_data)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _empfang_normalize_section_body_for_shell(self, raw: str, *, kind: str) -> str:
|
||
|
|
"""Nur Textkoerper Therapie bzw. Procedere fuer WebView/RAM-Kontext (keine ganze KG).
|
||
|
|
|
||
|
|
Beispiel-Struktur (manueller Test aus AzA KG)::
|
||
|
|
|
||
|
|
Diagnose …
|
||
|
|
Therapie …
|
||
|
|
Kryotherapie …
|
||
|
|
|
||
|
|
Procedere …
|
||
|
|
Folgetermin …
|
||
|
|
|
||
|
|
Ergebnis: Therapiekarte ohne Diagnose-/Procedere-Anteile; Procederekarte ohne Therapie.
|
||
|
|
"""
|
||
|
|
s = (raw or "").strip()
|
||
|
|
if not s:
|
||
|
|
return ""
|
||
|
|
if len(s) > 62000 or s.count("\n") > 260:
|
||
|
|
return ""
|
||
|
|
lines = list(s.splitlines())
|
||
|
|
headings_ther = frozenset({
|
||
|
|
"therapie", "therapieplan", "behandlung", "behandlungsplan",
|
||
|
|
"therapeutisches vorgehen", "aktuelle therapie", "therapieempfehlung",
|
||
|
|
})
|
||
|
|
headings_proc = frozenset({
|
||
|
|
"procedere", "prozedere", "weiteres procedere", "weiteres vorgehen",
|
||
|
|
"nächste schritte", "nachste schritte", "nachfolgendes procedere",
|
||
|
|
"empfohlenes procedere",
|
||
|
|
})
|
||
|
|
drops = headings_ther if kind == "ther" else headings_proc
|
||
|
|
|
||
|
|
def _normalize_heading_line(one: str) -> str:
|
||
|
|
t = (one or "").strip()
|
||
|
|
while t.startswith("*"):
|
||
|
|
t = t.lstrip("*").strip()
|
||
|
|
while t.endswith("*"):
|
||
|
|
t = t.rstrip("*").strip()
|
||
|
|
t = re.sub(r"^[\-\#\u2022\u25CF\u25AA\s]+", "", t)
|
||
|
|
t = re.sub(r"^\d+[\.\)]\s*", "", t)
|
||
|
|
return t.lower().rstrip(":").rstrip(".").strip()
|
||
|
|
|
||
|
|
while lines:
|
||
|
|
fl_norm = _normalize_heading_line(lines[0])
|
||
|
|
if fl_norm in drops:
|
||
|
|
lines = lines[1:]
|
||
|
|
continue
|
||
|
|
if kind == "ther" and fl_norm.startswith("therapie"):
|
||
|
|
lines = lines[1:]
|
||
|
|
continue
|
||
|
|
if kind == "proc" and fl_norm.startswith("procedere"):
|
||
|
|
lines = lines[1:]
|
||
|
|
continue
|
||
|
|
break
|
||
|
|
|
||
|
|
out = "\n".join(lines).strip()
|
||
|
|
if not out:
|
||
|
|
return ""
|
||
|
|
first_line = out.splitlines()[0].strip()
|
||
|
|
h0 = _normalize_heading_line(first_line)
|
||
|
|
if h0 in ("diagnose", "diagnosen"):
|
||
|
|
return ""
|
||
|
|
if kind == "ther" and h0 in headings_proc:
|
||
|
|
return ""
|
||
|
|
if kind == "proc" and h0 in headings_ther:
|
||
|
|
return ""
|
||
|
|
return out
|
||
|
|
|
||
|
|
def _empfang_desktop_shell_context_payload(self) -> dict:
|
||
|
|
snap = getattr(self, "_empfang_shell_section_snapshot", None)
|
||
|
|
if isinstance(snap, dict):
|
||
|
|
raw_ther = snap.get("therapieplan") or ""
|
||
|
|
raw_proc = snap.get("procedere") or ""
|
||
|
|
else:
|
||
|
|
ex = self._extract_kg_sections()
|
||
|
|
raw_ther = ex.get("therapieplan") or ""
|
||
|
|
raw_proc = ex.get("procedere") or ""
|
||
|
|
prefs = self._autotext_data.get("empfang_prefs") or {}
|
||
|
|
ther = self._empfang_normalize_section_body_for_shell(raw_ther, kind="ther")
|
||
|
|
proc = self._empfang_normalize_section_body_for_shell(raw_proc, kind="proc")
|
||
|
|
dm_uid = ""
|
||
|
|
dm_dn = ""
|
||
|
|
dm_mid = ""
|
||
|
|
pend = getattr(self, "_empfang_shell_dm_open_pending", None)
|
||
|
|
if isinstance(pend, dict):
|
||
|
|
dm_uid = str(pend.get("peer_user_id") or "").strip()[:64]
|
||
|
|
dm_dn = str(pend.get("display_name") or "").strip()[:200]
|
||
|
|
dm_mid = str(pend.get("message_id") or "").strip()[:64]
|
||
|
|
return {
|
||
|
|
"therapy_text": ther,
|
||
|
|
"procedure_text": proc,
|
||
|
|
"therapy_autocopy": bool(prefs.get("auto_copy_ther", False)),
|
||
|
|
"procedure_autocopy": bool(prefs.get("auto_copy_proc", False)),
|
||
|
|
"dm_open_peer_user_id": dm_uid,
|
||
|
|
"dm_open_display_name": dm_dn,
|
||
|
|
"dm_open_msg_id": dm_mid,
|
||
|
|
}
|
||
|
|
|
||
|
|
def _post_empfang_shell_context_safe(self) -> bool:
|
||
|
|
"""POST /empfang/shell/context — nur Groessen in Logs (Server), kein Klartext."""
|
||
|
|
try:
|
||
|
|
bu = self.get_backend_url()
|
||
|
|
payload = self._empfang_desktop_shell_context_payload()
|
||
|
|
r = requests.post(
|
||
|
|
f"{bu}/empfang/shell/context",
|
||
|
|
headers=self._empfang_headers(),
|
||
|
|
json=payload,
|
||
|
|
timeout=15,
|
||
|
|
)
|
||
|
|
ok = r.status_code == 200
|
||
|
|
if ok:
|
||
|
|
setattr(self, "_empfang_shell_dm_open_pending", None)
|
||
|
|
return ok
|
||
|
|
except Exception:
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _empfang_incoming_sender_is_other(self, m: dict) -> bool:
|
||
|
|
"""True wenn Nachricht nicht vom eigenen Empfang-Anzeigenamen stammt."""
|
||
|
|
me_raw = (self._empfang_self_display_name() or "").strip()
|
||
|
|
if not me_raw:
|
||
|
|
return True
|
||
|
|
me_k = _empfang_identity_key(me_raw)
|
||
|
|
snd = (m.get("absender") or "")
|
||
|
|
core = (snd or "").split("(", 1)[0].strip()
|
||
|
|
return _empfang_identity_key(core) != me_k
|
||
|
|
|
||
|
|
def _empfang_native_alert_message_targets_me(self, m: dict) -> bool:
|
||
|
|
"""True, wenn die Nachricht fuer den aktuellen Desktop-Benutzer relevant ist."""
|
||
|
|
ex = m.get("extras") if isinstance(m.get("extras"), dict) else {}
|
||
|
|
me = (self._empfang_self_user_id() or "").strip()
|
||
|
|
if not me:
|
||
|
|
return True
|
||
|
|
ru = str(ex.get("recipient_user_id") or "").strip()
|
||
|
|
rlist = ex.get("recipients")
|
||
|
|
is_multi = isinstance(rlist, list) and len(rlist) >= 2
|
||
|
|
rcpt_raw = (ex.get("recipient") or "").strip().lower()
|
||
|
|
broadcast = not rcpt_raw or rcpt_raw in ("alle", "all", "allgemein")
|
||
|
|
if ru and not broadcast and not is_multi:
|
||
|
|
if ru != me:
|
||
|
|
return False
|
||
|
|
return True
|
||
|
|
|
||
|
|
def _empfang_native_alert_is_incoming(self, m: dict) -> bool:
|
||
|
|
"""Eingehende fremde Nachricht (nicht eigenes Senden), fuer Desktop-Popup."""
|
||
|
|
if m.get("status") != "offen":
|
||
|
|
return False
|
||
|
|
if not self._empfang_native_alert_message_targets_me(m):
|
||
|
|
return False
|
||
|
|
ex = m.get("extras") if isinstance(m.get("extras"), dict) else {}
|
||
|
|
if bool(ex.get("chat_ack")):
|
||
|
|
return False
|
||
|
|
me = (self._empfang_self_user_id() or "").strip()
|
||
|
|
su = str(ex.get("sender_user_id") or "").strip()
|
||
|
|
if me and su and su == me:
|
||
|
|
return False
|
||
|
|
if me and su and su != me:
|
||
|
|
return True
|
||
|
|
return self._empfang_incoming_sender_is_other(m)
|
||
|
|
|
||
|
|
def _empfang_native_alert_raise(self, top: tk.Misc) -> None:
|
||
|
|
try:
|
||
|
|
if not top.winfo_exists():
|
||
|
|
return
|
||
|
|
except Exception:
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
top.deiconify()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
top.wm_state("normal")
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
top.attributes("-topmost", True)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
top.lift()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
top.focus_force()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
self.after(600, lambda t=top: self._empfang_native_alert_clear_topmost(t))
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _empfang_native_alert_clear_topmost(self, top: tk.Misc) -> None:
|
||
|
|
try:
|
||
|
|
if top.winfo_exists():
|
||
|
|
top.attributes("-topmost", False)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _empfang_offer_tk_fallback(self, parent: tk.Misc, msg: str) -> None:
|
||
|
|
"""Kein produktiver Tkinter-Ersatz fuer die Web-Empfangshuelle.
|
||
|
|
|
||
|
|
Nur wenn AZA_EMPFANG_ALLOW_TK_FALLBACK=1 und nicht frozen: optional Dev-Oeffnung der Legacy-Maske.
|
||
|
|
"""
|
||
|
|
dev_tk = (
|
||
|
|
not getattr(sys, "frozen", False)
|
||
|
|
and str(os.getenv("AZA_EMPFANG_ALLOW_TK_FALLBACK", "")).strip() == "1"
|
||
|
|
)
|
||
|
|
if dev_tk:
|
||
|
|
if messagebox.askyesno(
|
||
|
|
"Empfang-H\u00fclle",
|
||
|
|
(msg or "WebView-H\u00fclle nicht gestartet.") + "\n\n"
|
||
|
|
"Klassische Tkinter-Empfangsmaske stattdessen \u00f6ffnen? (nur Entwicklung)",
|
||
|
|
parent=parent,
|
||
|
|
):
|
||
|
|
try:
|
||
|
|
self._open_empfang_tk_dialog_legacy()
|
||
|
|
except Exception as exc:
|
||
|
|
messagebox.showerror("Empfang", str(exc), parent=parent)
|
||
|
|
return
|
||
|
|
if getattr(sys, "frozen", False):
|
||
|
|
text = (
|
||
|
|
"AZA Chat ist nicht installiert oder wurde nicht gefunden.\n\n"
|
||
|
|
"Bitte f\u00fchren Sie den Setup-Assistenten erneut aus und stellen Sie sicher, "
|
||
|
|
"dass die Komponente \u00abAZA Chat\u00bb aktiviert ist "
|
||
|
|
"(bei AZA Office standardm\u00e4ssig dabei).\n\n"
|
||
|
|
"Wenn Sie Hilfe ben\u00f6tigen, wenden Sie sich bitte an den Support."
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
m = (msg or "Die Empfang-H\u00fclle konnte nicht gestartet werden.").strip()
|
||
|
|
text = (
|
||
|
|
m
|
||
|
|
+ "\n\n"
|
||
|
|
+ "Bitte pr\u00fcfen Sie, ob AZA_EmpfangShell.exe gebaut ist (dist-Ordner) "
|
||
|
|
+ "oder ob aza_empfang_webview.py im Projekt liegt."
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
messagebox.showerror("AZA Chat", text, parent=parent)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _empfang_open_webview_singleton(
|
||
|
|
self,
|
||
|
|
*,
|
||
|
|
gui_parent: Optional[tk.Misc] = None,
|
||
|
|
silent_status: bool = False,
|
||
|
|
offer_tk_on_shell_fail: bool = True,
|
||
|
|
) -> None:
|
||
|
|
"""Startet h\u00f6chstens eine WebView-H\u00fclle; l\u00e4uft sie schon, Fokus statt neuem Prozess.
|
||
|
|
|
||
|
|
Schutz gegen doppelte Starts: in-memory Popen-Ref UND Window-HWND zaehlen als alive.
|
||
|
|
Zusaetzlich Launch-in-flight-Lock fuer ~12 s, damit waehrend HTTP-Shell-Session-Fetch
|
||
|
|
keine zweite Empfang-Auto-Popup denselben Pfad nochmal startet (Token 1x verbrauchbar).
|
||
|
|
"""
|
||
|
|
_par = gui_parent if gui_parent is not None else self
|
||
|
|
self._empfang_webview_prune_dead_proc()
|
||
|
|
existing_alive = self._empfang_webview_subprocess_alive()
|
||
|
|
hwnd_now = self._empfang_webview_resolve_shell_hwnd() if existing_alive else 0
|
||
|
|
self._empfang_shell_diag_log(
|
||
|
|
source="main",
|
||
|
|
action=("focus_existing" if existing_alive else "spawn"),
|
||
|
|
existing_proc_alive=existing_alive,
|
||
|
|
hwnd_found=bool(hwnd_now),
|
||
|
|
child_pid=getattr(self, "_empfang_webview_last_child_pid", None),
|
||
|
|
popen_pid=(
|
||
|
|
getattr(self._empfang_webview_proc, "pid", None)
|
||
|
|
if getattr(self, "_empfang_webview_proc", None) is not None
|
||
|
|
else None
|
||
|
|
),
|
||
|
|
)
|
||
|
|
if existing_alive:
|
||
|
|
try:
|
||
|
|
self._prepare_empfang_prefs_for_webview()
|
||
|
|
self._post_empfang_shell_context_safe()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
self._empfang_shell_focus_existing_instances()
|
||
|
|
if not silent_status:
|
||
|
|
try:
|
||
|
|
self.set_status("Empfangs-Chat geöffnet.")
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
self._maybe_autostart_kontakt_panel()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
return
|
||
|
|
|
||
|
|
if not self._empfang_webview_launch_lock.acquire(blocking=False):
|
||
|
|
self._empfang_shell_diag_log(
|
||
|
|
source="main",
|
||
|
|
action="skip",
|
||
|
|
skip_reason="launch_lock_busy",
|
||
|
|
)
|
||
|
|
self._empfang_shell_focus_existing_instances()
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
now_ts = time.time()
|
||
|
|
infl = float(self._empfang_webview_launch_inflight_until or 0.0)
|
||
|
|
if infl and now_ts < infl:
|
||
|
|
if self._singleton_inflight_treat_as_alive(
|
||
|
|
inflight_until=infl,
|
||
|
|
launch_ts=float(getattr(self, "_empfang_webview_launch_ts", 0.0) or 0.0),
|
||
|
|
proc=getattr(self, "_empfang_webview_proc", None),
|
||
|
|
last_child_pid=int(getattr(self, "_empfang_webview_last_child_pid", 0) or 0),
|
||
|
|
title_prefixes=self._EMPFANG_SHELL_TITLE_PREFIXES,
|
||
|
|
):
|
||
|
|
self._empfang_shell_diag_log(
|
||
|
|
source="main",
|
||
|
|
action="skip",
|
||
|
|
skip_reason="launch_inflight",
|
||
|
|
)
|
||
|
|
self._empfang_shell_focus_existing_instances()
|
||
|
|
return
|
||
|
|
self._empfang_webview_launch_inflight_until = 0.0
|
||
|
|
self._empfang_webview_launch_ts = now_ts
|
||
|
|
self._empfang_webview_launch_inflight_until = now_ts + 12.0
|
||
|
|
finally:
|
||
|
|
try:
|
||
|
|
self._empfang_webview_launch_lock.release()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _worker():
|
||
|
|
try:
|
||
|
|
self._post_empfang_shell_context_safe()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
data, err = self._fetch_empfang_shell_session_dict()
|
||
|
|
if err:
|
||
|
|
try:
|
||
|
|
self._empfang_webview_launch_inflight_until = time.time() + 2.0
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
self._empfang_shell_diag_log(
|
||
|
|
source="main",
|
||
|
|
action="abort",
|
||
|
|
has_token=False,
|
||
|
|
skip_reason="shell_session_error",
|
||
|
|
)
|
||
|
|
|
||
|
|
def _fail_shell():
|
||
|
|
if silent_status:
|
||
|
|
try:
|
||
|
|
self.set_status(
|
||
|
|
"Empfang (Auto): Shell-Session nicht m\u00f6glich — bitte sp\u00e4ter erneut.",
|
||
|
|
)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
return
|
||
|
|
if offer_tk_on_shell_fail:
|
||
|
|
self._empfang_offer_tk_fallback(_par, err)
|
||
|
|
else:
|
||
|
|
messagebox.showerror("Empfang-H\u00fclle", err, parent=_par)
|
||
|
|
|
||
|
|
self.after(0, _fail_shell)
|
||
|
|
return
|
||
|
|
|
||
|
|
shell_tok = (data.get("shell_token") if isinstance(data, dict) else None) or ""
|
||
|
|
shell_tok = str(shell_tok).strip()
|
||
|
|
if not shell_tok:
|
||
|
|
try:
|
||
|
|
self._empfang_webview_launch_inflight_until = time.time() + 2.0
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
self._empfang_shell_diag_log(
|
||
|
|
source="main",
|
||
|
|
action="abort",
|
||
|
|
has_token=False,
|
||
|
|
skip_reason="no_shell_token",
|
||
|
|
)
|
||
|
|
|
||
|
|
def _no_tok():
|
||
|
|
msg = "Antwort ohne shell_token vom Server."
|
||
|
|
if silent_status:
|
||
|
|
try:
|
||
|
|
self.set_status("Empfang (Auto): " + msg)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
return
|
||
|
|
if offer_tk_on_shell_fail:
|
||
|
|
self._empfang_offer_tk_fallback(_par, msg)
|
||
|
|
else:
|
||
|
|
messagebox.showerror("Empfang-H\u00fclle", msg, parent=_par)
|
||
|
|
|
||
|
|
self.after(0, _no_tok)
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
base = self._empfang_shell_frontend_base_url()
|
||
|
|
except Exception as ex:
|
||
|
|
try:
|
||
|
|
self._empfang_webview_launch_inflight_until = time.time() + 2.0
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _be():
|
||
|
|
if silent_status:
|
||
|
|
self.set_status(f"Empfang: {ex}")
|
||
|
|
elif offer_tk_on_shell_fail:
|
||
|
|
self._empfang_offer_tk_fallback(_par, str(ex))
|
||
|
|
else:
|
||
|
|
messagebox.showerror("Empfang-H\u00fclle", str(ex), parent=_par)
|
||
|
|
|
||
|
|
self.after(0, _be)
|
||
|
|
return
|
||
|
|
|
||
|
|
launch_url = (
|
||
|
|
f"{base}/empfang/shell/launch?token={quote(shell_tok, safe='')}"
|
||
|
|
f"&target=empfang_chat_shell"
|
||
|
|
)
|
||
|
|
exe_bundle = self._empfang_shell_webview_bundle_path()
|
||
|
|
starter = self._empfang_shell_webview_script_path()
|
||
|
|
if exe_bundle is not None:
|
||
|
|
cmd = [str(exe_bundle), launch_url]
|
||
|
|
cwd = str(exe_bundle.parent)
|
||
|
|
runner = "exe"
|
||
|
|
exe_hint = str(exe_bundle)
|
||
|
|
elif starter.is_file():
|
||
|
|
cmd = [sys.executable, str(starter), launch_url]
|
||
|
|
cwd = str(starter.parent)
|
||
|
|
runner = "python"
|
||
|
|
exe_hint = str(starter)
|
||
|
|
else:
|
||
|
|
self._empfang_shell_diag_log(
|
||
|
|
source="main",
|
||
|
|
action="abort",
|
||
|
|
runner="missing",
|
||
|
|
url_path="/empfang/shell/launch?token=(redacted)",
|
||
|
|
has_token=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
def _st():
|
||
|
|
msg = f"Empfang-Starter nicht gefunden (weder AZA_EmpfangShell.exe noch {starter.name})."
|
||
|
|
if silent_status:
|
||
|
|
try:
|
||
|
|
self.set_status("Empfang: Starter fehlt.")
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
return
|
||
|
|
if offer_tk_on_shell_fail:
|
||
|
|
self._empfang_offer_tk_fallback(_par, msg)
|
||
|
|
else:
|
||
|
|
if getattr(sys, "frozen", False):
|
||
|
|
messagebox.showerror(
|
||
|
|
"AZA Chat",
|
||
|
|
"AZA Chat ist nicht installiert oder wurde nicht gefunden.\n\n"
|
||
|
|
"Bitte f\u00fchren Sie den Setup-Assistenten erneut aus und aktivieren Sie "
|
||
|
|
"die Komponente \u00abAZA Chat\u00bb.\n\n"
|
||
|
|
"Bei Fragen wenden Sie sich bitte an den Support.",
|
||
|
|
parent=_par,
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
messagebox.showerror(
|
||
|
|
"Empfang-H\u00fclle",
|
||
|
|
msg + "\n\n(Bitte build_exe.ps1 ausfuehren oder Projekt pruefen.)",
|
||
|
|
parent=_par,
|
||
|
|
)
|
||
|
|
|
||
|
|
self.after(0, _st)
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
popen_kw = {"cwd": cwd, "close_fds": (sys.platform != "win32"), "env": self._empfang_child_env()}
|
||
|
|
if sys.platform == "win32":
|
||
|
|
_pflags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0)
|
||
|
|
_pflags |= int(getattr(subprocess, "DETACHED_PROCESS", 0) or 0)
|
||
|
|
_pflags |= int(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) or 0)
|
||
|
|
if _pflags:
|
||
|
|
popen_kw["creationflags"] = _pflags
|
||
|
|
popen_kw["stdin"] = subprocess.DEVNULL
|
||
|
|
popen_kw["stdout"] = subprocess.DEVNULL
|
||
|
|
popen_kw["stderr"] = subprocess.DEVNULL
|
||
|
|
self._empfang_webview_proc = subprocess.Popen(cmd, **popen_kw)
|
||
|
|
try:
|
||
|
|
self._empfang_webview_last_child_pid = int(self._empfang_webview_proc.pid)
|
||
|
|
self._aza_track_external_pid(self._empfang_webview_last_child_pid)
|
||
|
|
except Exception:
|
||
|
|
self._empfang_webview_last_child_pid = None
|
||
|
|
for _d in (800, 2000, 4000):
|
||
|
|
self.after(
|
||
|
|
_d,
|
||
|
|
lambda: self._empfang_shell_refresh_tracked_pid_from_hwnd(),
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
self._empfang_webview_launch_inflight_until = time.time() + 2.0
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
self._empfang_shell_diag_log(
|
||
|
|
source="main",
|
||
|
|
action="spawned",
|
||
|
|
runner=runner,
|
||
|
|
url_path="/empfang/shell/launch?token=(redacted)",
|
||
|
|
has_token=True,
|
||
|
|
popen_pid=getattr(self._empfang_webview_proc, "pid", None),
|
||
|
|
child_pid=self._empfang_webview_last_child_pid,
|
||
|
|
exe_hint=exe_hint[:220] if exe_hint else "",
|
||
|
|
)
|
||
|
|
except Exception as exc:
|
||
|
|
try:
|
||
|
|
self._empfang_webview_launch_inflight_until = time.time() + 2.0
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
self._empfang_shell_diag_log(
|
||
|
|
source="main",
|
||
|
|
action="abort",
|
||
|
|
runner=runner,
|
||
|
|
skip_reason="popen_failed",
|
||
|
|
)
|
||
|
|
|
||
|
|
def _pex():
|
||
|
|
msg = f"Fenster-Start fehlgeschlagen:\n{exc}\n(pywebview installiert?)"
|
||
|
|
if silent_status:
|
||
|
|
try:
|
||
|
|
self.set_status("Empfang (Auto): Fensterstart fehlgeschlagen.")
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
return
|
||
|
|
if offer_tk_on_shell_fail:
|
||
|
|
self._empfang_offer_tk_fallback(_par, msg)
|
||
|
|
else:
|
||
|
|
messagebox.showerror("Empfang-H\u00fclle", msg, parent=_par)
|
||
|
|
|
||
|
|
self.after(0, _pex)
|
||
|
|
return
|
||
|
|
|
||
|
|
def _ok_stat():
|
||
|
|
if not silent_status:
|
||
|
|
try:
|
||
|
|
self.set_status(
|
||
|
|
"Empfang-Web-H\u00fclle gestartet (eigenes Fenster, Shell-Session).",
|
||
|
|
)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
self.after(0, _ok_stat)
|
||
|
|
try:
|
||
|
|
self.after(1500, self._maybe_autostart_kontakt_panel)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
threading.Thread(target=_worker, daemon=True).start()
|
||
|
|
|
||
|
|
def _empfang_schedule_context_autopopup(self, reason: str) -> None:
|
||
|
|
"""Bei aktivem Chat-Empfang: WebView bei neuem Diktat-/KG-Kontext (entprellt)."""
|
||
|
|
if not self._autotext_data.get("empfang_auto_open", False):
|
||
|
|
return
|
||
|
|
now = time.time()
|
||
|
|
if now - float(getattr(self, "_empfang_ctx_autopopup_last_ts", 0.0) or 0.0) < 6.0:
|
||
|
|
return
|
||
|
|
tr = self.txt_transcript.get("1.0", "end").strip()
|
||
|
|
kg = self.txt_output.get("1.0", "end").strip()
|
||
|
|
if reason == "transcript":
|
||
|
|
if len(tr) < 80:
|
||
|
|
return
|
||
|
|
elif reason == "kg_ready":
|
||
|
|
if not kg.strip():
|
||
|
|
return
|
||
|
|
else:
|
||
|
|
return
|
||
|
|
sig_src = f"{reason}|{tr[:3000]}|{kg[:3000]}"
|
||
|
|
sig = hashlib.sha256(sig_src.encode("utf-8", errors="ignore")).hexdigest()[:24]
|
||
|
|
if sig == getattr(self, "_empfang_ctx_autopopup_last_sig", ""):
|
||
|
|
return
|
||
|
|
self._empfang_ctx_autopopup_last_sig = sig
|
||
|
|
self._empfang_ctx_autopopup_last_ts = now
|
||
|
|
self._prepare_empfang_prefs_for_webview()
|
||
|
|
self.after(
|
||
|
|
120,
|
||
|
|
lambda: self._empfang_open_webview_singleton(
|
||
|
|
gui_parent=self,
|
||
|
|
silent_status=True,
|
||
|
|
offer_tk_on_shell_fail=False,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
def _empfang_maybe_autopopup_from_poll(self, new_ids: set, msgs: list) -> None:
|
||
|
|
"""Bei aktivem Chat-Empfang: WebView bei neuer fremder Nachricht (wie Toast-Logik, entprellt)."""
|
||
|
|
if not self._autotext_data.get("empfang_auto_open", False):
|
||
|
|
return
|
||
|
|
if not new_ids:
|
||
|
|
return
|
||
|
|
new_ids_nm = {str(x) for x in new_ids}
|
||
|
|
now = time.time()
|
||
|
|
if now - float(getattr(self, "_empfang_poll_autopopup_last_ts", 0.0) or 0.0) < 4.0:
|
||
|
|
return
|
||
|
|
candidates = []
|
||
|
|
for m in msgs:
|
||
|
|
if not isinstance(m, dict):
|
||
|
|
continue
|
||
|
|
mid = m.get("id")
|
||
|
|
if mid is None:
|
||
|
|
continue
|
||
|
|
sid = str(mid)
|
||
|
|
if sid not in new_ids_nm:
|
||
|
|
continue
|
||
|
|
if m.get("status") != "offen":
|
||
|
|
continue
|
||
|
|
if not self._empfang_native_alert_is_incoming(m):
|
||
|
|
continue
|
||
|
|
candidates.append(sid)
|
||
|
|
if not candidates:
|
||
|
|
return
|
||
|
|
trigger_id = None
|
||
|
|
for sid in sorted(candidates):
|
||
|
|
if sid in self._empfang_auto_popup_seen_ids:
|
||
|
|
continue
|
||
|
|
trigger_id = sid
|
||
|
|
break
|
||
|
|
if trigger_id is None:
|
||
|
|
return
|
||
|
|
self._empfang_auto_popup_seen_ids.add(trigger_id)
|
||
|
|
if len(self._empfang_auto_popup_seen_ids) > 400:
|
||
|
|
self._empfang_auto_popup_seen_ids = set(
|
||
|
|
list(self._empfang_auto_popup_seen_ids)[-200:]
|
||
|
|
)
|
||
|
|
self._empfang_poll_autopopup_last_ts = now
|
||
|
|
# Autom. Vollhülle-Öffnung deaktiviert: das native Benachrichtigungs-Popup
|
||
|
|
# (_show_empfang_chat_message_alert) wird bereits separat gezeigt und
|
||
|
|
# öffnet den Chat gezielt mit dem richtigen Absender wenn gewünscht.
|
||
|
|
# Die große Hülle nicht automatisch öffnen — Benutzer entscheidet via Popup.
|
||
|
|
|
||
|
|
def _empfang_shell_frontend_base_url(self) -> str:
|
||
|
|
"""Origin fuer Shell-Launch: muss /empfang/shell/ ausliefern (Cookie-Host = Session-Host).
|
||
|
|
|
||
|
|
Wenn AZA_EMPFANG_WEB_BASE gesetzt ist: dieser Host — muss dann auch die API-/shell Routen erreichen."""
|
||
|
|
pub = self._clean_backend_value(os.getenv("AZA_EMPFANG_WEB_BASE"))
|
||
|
|
if pub:
|
||
|
|
return pub.rstrip("/")
|
||
|
|
if os.environ.get("AZA_DOKU_PROMPT_TEST", "").strip().lower() in ("1", "true", "yes"):
|
||
|
|
try:
|
||
|
|
from aza_empfang_test_html_proxy import test_proxy_base_url
|
||
|
|
|
||
|
|
proxy = test_proxy_base_url()
|
||
|
|
if proxy:
|
||
|
|
return proxy.rstrip("/")
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
return self.get_backend_url().rstrip("/")
|
||
|
|
|
||
|
|
def _empfang_child_env(self) -> dict[str, str]:
|
||
|
|
"""Proxy-/Test-Env explizit an Shell-Subprozesse weitergeben."""
|
||
|
|
env = os.environ.copy()
|
||
|
|
for key in (
|
||
|
|
"AZA_DOKU_PROMPT_TEST",
|
||
|
|
"AZA_EMPFANG_WEB_BASE",
|
||
|
|
"AZA_EMPFANG_CHAT_SHELL_URL",
|
||
|
|
"AZA_EMPFANG_TEST_PROXY_PORT",
|
||
|
|
"AZA_EMPFANG_TEST_UPSTREAM",
|
||
|
|
):
|
||
|
|
val = os.getenv(key)
|
||
|
|
if val is not None and str(val).strip() != "":
|
||
|
|
env[key] = str(val).strip()
|
||
|
|
return env
|
||
|
|
|
||
|
|
def _empfang_shell_webview_script_path(self) -> Path:
|
||
|
|
"""Separater Starter als subprocess (pywebview, kein Tk-Blockierung)."""
|
||
|
|
return Path(__file__).resolve().parent / "aza_empfang_webview.py"
|
||
|
|
|
||
|
|
def _kontakt_panel_starter(self) -> Optional[list]:
|
||
|
|
"""Starter-Kommando fuer das AzA Kontakt-Panel (EXE bevorzugt, sonst Python-Skript).
|
||
|
|
|
||
|
|
Rueckgabe: cmd-Prefix-Liste OHNE URL (URL wird vom Aufrufer angehaengt) oder None,
|
||
|
|
wenn weder Bundle-EXE noch Python-Skript gefunden werden.
|
||
|
|
"""
|
||
|
|
candidates: list[Path] = []
|
||
|
|
try:
|
||
|
|
if getattr(sys, "frozen", False):
|
||
|
|
_exe_dir = Path(sys.executable).resolve().parent
|
||
|
|
candidates.append(_exe_dir / "AZA_KontaktPanel.exe")
|
||
|
|
candidates.append(_exe_dir / "_internal" / "AZA_KontaktPanel.exe")
|
||
|
|
meip = getattr(sys, "_MEIPASS", "")
|
||
|
|
if meip:
|
||
|
|
candidates.append(Path(meip) / "AZA_KontaktPanel.exe")
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
root = Path(__file__).resolve().parent
|
||
|
|
candidates.append(root / "AZA_KontaktPanel.exe")
|
||
|
|
candidates.append(root / "dist" / "AZA_KontaktPanel.exe")
|
||
|
|
for exe in candidates:
|
||
|
|
try:
|
||
|
|
if exe.is_file():
|
||
|
|
return [str(exe)]
|
||
|
|
except Exception:
|
||
|
|
continue
|
||
|
|
script = root / "aza_kontakt_panel.py"
|
||
|
|
if script.is_file():
|
||
|
|
return [sys.executable, str(script)]
|
||
|
|
return None
|
||
|
|
|
||
|
|
def _maybe_autostart_kontakt_panel(self) -> None:
|
||
|
|
"""Startet das AzA Kontakt-Panel genau einmal zusammen mit dem Chat.
|
||
|
|
|
||
|
|
- Singleton: laeuft bereits ein Panel-Prozess, passiert nichts.
|
||
|
|
- Login-Handoff: derselbe sichere Einmal-Token wie die Empfang-Huelle
|
||
|
|
(``_fetch_empfang_shell_session_dict`` -> ``/empfang/shell/launch``),
|
||
|
|
gebunden an practice_id/user_id, kurzlebig, einmalig. Kein Passwort.
|
||
|
|
- Nicht-blockierend (Daemon-Thread); jeder Fehler wird verschluckt und
|
||
|
|
beeinflusst den Hauptchat-Start nicht.
|
||
|
|
"""
|
||
|
|
if self._kontakt_panel_subprocess_alive():
|
||
|
|
try:
|
||
|
|
from aza_empfang_shell_surface import touch_shell_peer_refresh_signal
|
||
|
|
|
||
|
|
touch_shell_peer_refresh_signal(source="kontakt_bring")
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
self._kontakt_panel_try_bring_to_front(0)
|
||
|
|
self._kontakt_panel_try_bring_to_front(500)
|
||
|
|
return
|
||
|
|
if not self._kontakt_panel_launch_lock.acquire(blocking=False):
|
||
|
|
self._kontakt_panel_try_bring_to_front(0)
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
now = time.time()
|
||
|
|
kinfl = float(getattr(self, "_kontakt_panel_inflight_until", 0.0) or 0.0)
|
||
|
|
if kinfl and now < kinfl:
|
||
|
|
if self._singleton_inflight_treat_as_alive(
|
||
|
|
inflight_until=kinfl,
|
||
|
|
launch_ts=float(getattr(self, "_kontakt_panel_launch_ts", 0.0) or 0.0),
|
||
|
|
proc=getattr(self, "_kontakt_panel_proc", None),
|
||
|
|
last_child_pid=int(getattr(self, "_kontakt_panel_last_child_pid", 0) or 0),
|
||
|
|
title_prefixes=self._KONTAKT_PANEL_TITLE_PREFIXES,
|
||
|
|
empty_grace_s=6.0,
|
||
|
|
):
|
||
|
|
self._kontakt_panel_try_bring_to_front(0)
|
||
|
|
return
|
||
|
|
self._kontakt_panel_inflight_until = 0.0
|
||
|
|
self._kontakt_panel_launch_ts = now
|
||
|
|
self._kontakt_panel_inflight_until = now + 12.0
|
||
|
|
finally:
|
||
|
|
try:
|
||
|
|
self._kontakt_panel_launch_lock.release()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
starter = self._kontakt_panel_starter()
|
||
|
|
if not starter:
|
||
|
|
return
|
||
|
|
|
||
|
|
def _worker() -> None:
|
||
|
|
spawned = False
|
||
|
|
try:
|
||
|
|
data, err = self._fetch_empfang_shell_session_dict()
|
||
|
|
if err or not isinstance(data, dict):
|
||
|
|
return
|
||
|
|
shell_tok = str(data.get("shell_token") or "").strip()
|
||
|
|
if not shell_tok:
|
||
|
|
return
|
||
|
|
base = self._empfang_shell_frontend_base_url()
|
||
|
|
launch_url = (
|
||
|
|
f"{base}/empfang/shell/launch?token={quote(shell_tok, safe='')}"
|
||
|
|
f"&target=kontakt_panel"
|
||
|
|
)
|
||
|
|
cmd = list(starter) + [launch_url]
|
||
|
|
popen_kw = {
|
||
|
|
"cwd": str(Path(starter[-1]).resolve().parent),
|
||
|
|
"close_fds": (sys.platform != "win32"),
|
||
|
|
"env": self._empfang_child_env(),
|
||
|
|
}
|
||
|
|
if sys.platform == "win32":
|
||
|
|
_pflags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0)
|
||
|
|
_pflags |= int(getattr(subprocess, "DETACHED_PROCESS", 0) or 0)
|
||
|
|
_pflags |= int(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) or 0)
|
||
|
|
if _pflags:
|
||
|
|
popen_kw["creationflags"] = _pflags
|
||
|
|
popen_kw["stdin"] = subprocess.DEVNULL
|
||
|
|
popen_kw["stdout"] = subprocess.DEVNULL
|
||
|
|
popen_kw["stderr"] = subprocess.DEVNULL
|
||
|
|
self._kontakt_panel_proc = subprocess.Popen(cmd, **popen_kw)
|
||
|
|
spawned = True
|
||
|
|
try:
|
||
|
|
self._kontakt_panel_last_child_pid = int(self._kontakt_panel_proc.pid)
|
||
|
|
self._aza_track_external_pid(self._kontakt_panel_last_child_pid)
|
||
|
|
except Exception:
|
||
|
|
self._kontakt_panel_last_child_pid = None
|
||
|
|
for _d in (800, 2000, 4000):
|
||
|
|
self.after(
|
||
|
|
_d,
|
||
|
|
lambda: self._kontakt_panel_refresh_tracked_pid_from_hwnd(),
|
||
|
|
)
|
||
|
|
self._kontakt_panel_inflight_until = time.time() + 2.0
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
finally:
|
||
|
|
if not spawned:
|
||
|
|
try:
|
||
|
|
self._kontakt_panel_inflight_until = 0.0
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
threading.Thread(target=_worker, daemon=True).start()
|