update
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
Restore: copy files from this folder back to project root (web/empfang.html from backups subpath as web/empfang.html).
|
||||
Files: empfang.html -> web/empfang.html, aza_empfang_webview.py, aza_empfang_smart_pick.py, basis14.py
|
||||
@@ -0,0 +1,219 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Globaler Empfang-Pinsel (Maus-Hook + Clipboard) — gemeinsame Basis fuer
|
||||
Tkinter-Dialog „An Empfang senden“ und Desktop-WebView-Huelle.
|
||||
|
||||
Keine Netz-/Server- oder Log-Ausgabe von Patientendaten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from typing import Any, Callable
|
||||
|
||||
from aza_persistence import _win_clipboard_get
|
||||
|
||||
try:
|
||||
from pynput.keyboard import Controller as KbdController, Key, KeyCode
|
||||
except ImportError:
|
||||
KbdController = None # type: ignore[misc, assignment]
|
||||
Key = None # type: ignore[misc, assignment]
|
||||
KeyCode = None # type: ignore[misc, assignment]
|
||||
|
||||
try:
|
||||
from pynput.mouse import Button as MouseButton, Listener as MouseListener
|
||||
_HAS_PYNPUT_MOUSE = True
|
||||
except ImportError:
|
||||
MouseButton = None # type: ignore[misc, assignment]
|
||||
MouseListener = None # type: ignore[misc, assignment]
|
||||
_HAS_PYNPUT_MOUSE = False
|
||||
|
||||
_SMART_LISTENER: list[Any] = [None]
|
||||
_SMART_RESET_UI: list[Callable[[], None] | None] = [None]
|
||||
# Set bei Abbruch (Pinsel togglen); verhindert Pick-Erkennung nach dem Stop.
|
||||
_PICK_SUPERSEEDED: threading.Event = threading.Event()
|
||||
|
||||
|
||||
def compact_and_validate_patient_nr_pick_text(raw: str) -> str:
|
||||
"""
|
||||
Extrahiert eine plausible Patienten-ID (2–12 Ziffern), konservativ.
|
||||
Ganze Saetze / sehr langer Text werden nicht uebernommen.
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
s = str(raw).strip()
|
||||
if not s or len(s) > 220:
|
||||
return ""
|
||||
compact_ws = re.sub(r"\s+", "", s)
|
||||
norm = re.sub(r"[\s\-\/\u00ad\.\u202f]+", "", compact_ws)
|
||||
if not norm:
|
||||
return ""
|
||||
letters = sum(1 for ch in norm if ch.isalpha())
|
||||
if letters > 4 and len(norm) > 28:
|
||||
m = re.search(r"\d{2,12}", norm)
|
||||
return m.group(0) if m else ""
|
||||
m = re.search(r"\d{2,12}", norm)
|
||||
return m.group(0) if m else ""
|
||||
|
||||
|
||||
def _smart_pick_run_ui_reset_only() -> None:
|
||||
fn = _SMART_RESET_UI[0]
|
||||
_SMART_RESET_UI[0] = None
|
||||
if fn:
|
||||
try:
|
||||
fn()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def stop_global_patient_nr_pick() -> None:
|
||||
_PICK_SUPERSEEDED.set()
|
||||
lis = _SMART_LISTENER[0]
|
||||
if lis is not None:
|
||||
try:
|
||||
lis.stop()
|
||||
except Exception:
|
||||
pass
|
||||
_SMART_LISTENER[0] = None
|
||||
_smart_pick_run_ui_reset_only()
|
||||
|
||||
|
||||
def _grab_thread_finish() -> None:
|
||||
"""Nach Clipboard-Polling: Listener ist durch return False schon inactive."""
|
||||
_SMART_LISTENER[0] = None
|
||||
if not _PICK_SUPERSEEDED.is_set():
|
||||
_smart_pick_run_ui_reset_only()
|
||||
_PICK_SUPERSEEDED.set()
|
||||
|
||||
|
||||
def start_global_patient_nr_pick(
|
||||
on_clipboard_pick: Callable[[str], None],
|
||||
*,
|
||||
reset_pick_ui: Callable[[], None] | None = None,
|
||||
armed_ui: Callable[[], None] | None = None,
|
||||
owner_tk=None,
|
||||
schedule_ui: Callable[[Callable[[], None]], None] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Startet einen globalen Pick-Zyklus (ein Freigabe-Event, dann Clipboard).
|
||||
|
||||
Returns:
|
||||
"started" — Hoerer aktiv, auf Markierung/Doppelklick warten.
|
||||
"cancelled" — Vorheriger Zyklus wurde abgebrochen (UI-Reset ausgefuehrt).
|
||||
"unavailable" — pynput/Tastatur nicht nutzbar.
|
||||
"""
|
||||
if _SMART_LISTENER[0] is not None:
|
||||
stop_global_patient_nr_pick()
|
||||
return "cancelled"
|
||||
|
||||
if (
|
||||
not _HAS_PYNPUT_MOUSE
|
||||
or MouseListener is None
|
||||
or MouseButton is None
|
||||
or KbdController is None
|
||||
or Key is None
|
||||
or KeyCode is None
|
||||
):
|
||||
return "unavailable"
|
||||
|
||||
_PICK_SUPERSEEDED.clear()
|
||||
|
||||
try:
|
||||
old_clip = ""
|
||||
if sys.platform == "win32":
|
||||
old_clip = (_win_clipboard_get() or "").strip()
|
||||
if not old_clip and owner_tk is not None:
|
||||
try:
|
||||
old_clip = owner_tk.clipboard_get().strip()
|
||||
except tk.TclError:
|
||||
old_clip = ""
|
||||
except Exception:
|
||||
old_clip = ""
|
||||
|
||||
_SMART_RESET_UI[0] = reset_pick_ui
|
||||
|
||||
if armed_ui:
|
||||
try:
|
||||
armed_ui()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def schedule(fn: Callable[[], None]) -> None:
|
||||
if schedule_ui is not None:
|
||||
schedule_ui(fn)
|
||||
else:
|
||||
# Nicht im pynput-Listener-Thread ausfuehren (lis.stop() / Clipboard wuerden riskieren).
|
||||
threading.Thread(target=fn, daemon=True).start()
|
||||
|
||||
started_ts = time.time()
|
||||
mouse_was_pressed = False
|
||||
press_time = 0.0
|
||||
|
||||
def on_click(x: object, y: object, button: object, pressed: bool) -> bool | None:
|
||||
nonlocal mouse_was_pressed, press_time
|
||||
assert MouseButton is not None
|
||||
if button != MouseButton.left:
|
||||
return None
|
||||
if pressed:
|
||||
if time.time() - started_ts > 0.3:
|
||||
mouse_was_pressed = True
|
||||
press_time = time.time()
|
||||
return None
|
||||
if not mouse_was_pressed:
|
||||
return None
|
||||
mouse_was_pressed = False
|
||||
hold_duration = time.time() - press_time
|
||||
was_drag = hold_duration > 0.20
|
||||
|
||||
time.sleep(0.05)
|
||||
try:
|
||||
kbd = KbdController()
|
||||
if not was_drag:
|
||||
from pynput.mouse import Controller as MController
|
||||
|
||||
mc = MController()
|
||||
mc.click(MouseButton.left, 2)
|
||||
time.sleep(0.1)
|
||||
with kbd.pressed(Key.ctrl):
|
||||
kbd.tap(KeyCode.from_char("c"))
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.38)
|
||||
|
||||
def grab() -> None:
|
||||
cur = ""
|
||||
try:
|
||||
for _attempt in range(8):
|
||||
cur = ""
|
||||
if sys.platform == "win32":
|
||||
cur = (_win_clipboard_get() or "").strip()
|
||||
if not cur and owner_tk is not None:
|
||||
try:
|
||||
cur = owner_tk.clipboard_get().strip()
|
||||
except tk.TclError:
|
||||
cur = ""
|
||||
if cur and cur != old_clip:
|
||||
break
|
||||
time.sleep(0.06)
|
||||
if cur and cur != old_clip:
|
||||
if not _PICK_SUPERSEEDED.is_set():
|
||||
try:
|
||||
on_clipboard_pick(cur)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
_grab_thread_finish()
|
||||
|
||||
schedule(grab)
|
||||
return False
|
||||
|
||||
assert MouseListener is not None
|
||||
ml = MouseListener(on_click=on_click)
|
||||
_SMART_LISTENER[0] = ml
|
||||
ml.start()
|
||||
return "started"
|
||||
@@ -0,0 +1,146 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AzA Empfang Web-Huelle: eigener Desktop-Prozess (pywebview).
|
||||
|
||||
Wird vom Desktop per subprocess gestartet, damit keine GUI-Kollision mit Tkinter entsteht.
|
||||
Argument: erste Start-URL (z.B. GET /empfang/shell/launch?token=...).
|
||||
|
||||
WebView2-Profil: pywebview nutzt standardmaessig ``private_mode=True`` (ephemeral) und
|
||||
speichert keine Site-Permissions zwischen Sessions. Für die Huelle setzen wir einen
|
||||
festen ``storage_path`` unter %APPDATA%\\AzA\\EmpfangWebView und ``private_mode=False``,
|
||||
damit z. B. Mikrofon-Erlaubnis für die App-URL persistiert (wie im normalen Edge-Profil),
|
||||
ohne den Systembrowser zu verwenden.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _empfang_webview_storage_dir() -> str:
|
||||
"""Eigener User-Data-Ordner, isoliert vom normalen Browser."""
|
||||
appdata = (os.environ.get("APPDATA") or "").strip()
|
||||
if appdata:
|
||||
return str(Path(appdata) / "AzA" / "EmpfangWebView")
|
||||
return str(Path.home() / ".aza_empfang_webview")
|
||||
|
||||
|
||||
class EmpfangWebviewApi:
|
||||
"""pywebview JS-API: globaler Patienten-Pinsel (Desktop), ohne Server/URL."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._window = None
|
||||
|
||||
def bind_window(self, window) -> None:
|
||||
self._window = window
|
||||
|
||||
def start_patient_nr_pick(self) -> dict:
|
||||
from aza_empfang_smart_pick import (
|
||||
compact_and_validate_patient_nr_pick_text,
|
||||
start_global_patient_nr_pick,
|
||||
)
|
||||
|
||||
win = self._window
|
||||
if win is None:
|
||||
return {"ok": False, "reason": "no_window"}
|
||||
|
||||
def js_reset() -> None:
|
||||
try:
|
||||
win.evaluate_js(
|
||||
"try{window.shellFinalizeGlobalPickMechanismUi&&window.shellFinalizeGlobalPickMechanismUi();}catch(_e){}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_clipboard(raw: str) -> None:
|
||||
nn = compact_and_validate_patient_nr_pick_text(raw)
|
||||
if not nn:
|
||||
try:
|
||||
win.evaluate_js(
|
||||
"try{window.shellReceivePatientNrPickInvalid&&window.shellReceivePatientNrPickInvalid();}catch(_e){}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
try:
|
||||
win.evaluate_js(
|
||||
"try{window.shellReceivePatientNrFromDesktop(%s);}catch(_e){}"
|
||||
% json.dumps(nn),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rc = start_global_patient_nr_pick(
|
||||
on_clipboard_pick=on_clipboard,
|
||||
reset_pick_ui=js_reset,
|
||||
owner_tk=None,
|
||||
schedule_ui=None,
|
||||
)
|
||||
if rc == "cancelled":
|
||||
return {"ok": True, "cancelled": True}
|
||||
if rc == "unavailable":
|
||||
try:
|
||||
win.evaluate_js(
|
||||
"try{window.shellGlobalPinselResetUi&&window.shellGlobalPinselResetUi();}catch(_e){}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "reason": "no_input_hook"}
|
||||
return {"ok": True}
|
||||
|
||||
def cancel_patient_nr_pick(self) -> dict:
|
||||
from aza_empfang_smart_pick import stop_global_patient_nr_pick
|
||||
|
||||
stop_global_patient_nr_pick()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = argv if argv is not None else sys.argv[1:]
|
||||
if not argv or not argv[0].strip():
|
||||
print(
|
||||
"Usage:\n"
|
||||
' python aza_empfang_webview.py "https://host/empfang/shell/launch?token=..."',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
url = argv[0].strip()
|
||||
w = int(argv[1]) if len(argv) > 1 and str(argv[1]).isdigit() else 1180
|
||||
h = int(argv[2]) if len(argv) > 2 and str(argv[2]).isdigit() else 820
|
||||
|
||||
storage_path = _empfang_webview_storage_dir()
|
||||
try:
|
||||
Path(storage_path).mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"WebView-Profil-Ordner konnte nicht angelegt werden ({storage_path}): {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 13
|
||||
|
||||
try:
|
||||
import webview # noqa: WPS433 (runtime dependency)
|
||||
except ImportError:
|
||||
print(
|
||||
"pywebview fehlt. Bitte installieren:\n"
|
||||
" pip install pywebview>=5",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 11
|
||||
|
||||
api = EmpfangWebviewApi()
|
||||
try:
|
||||
win = webview.create_window("AzA-Empfang", url, width=w, height=h, js_api=api)
|
||||
api.bind_window(win)
|
||||
webview.start(storage_path=storage_path, private_mode=False)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 12
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user