This commit is contained in:
Your Name
2026-08-10 17:29:05 +08:00
parent 2199887c07
commit 9add23e019
129 changed files with 34157 additions and 59 deletions
@@ -0,0 +1,23 @@
"""Optional video-call integration for the doctor workstation."""
from .launcher import (
BackendMode,
VideoCallLauncher,
VideoCallRequest,
VideoTicketError,
launch_video_call,
normalize_backend_ticket,
require_supported_backend,
)
from .lifecycle import OrderedCallLifecycle
__all__ = [
"BackendMode",
"OrderedCallLifecycle",
"VideoCallLauncher",
"VideoCallRequest",
"VideoTicketError",
"launch_video_call",
"normalize_backend_ticket",
"require_supported_backend",
]
@@ -0,0 +1,405 @@
"""Pure-Python contract and launcher for the optional video companion.
The backend is the only authority that may issue ``userSig``. This module
normalizes that short-lived ticket and deliberately keeps Qt imports out of the
contract layer so it remains importable in core-only installations and tests.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any
class VideoTicketError(ValueError):
"""Raised when a backend video ticket is incomplete or unsafe."""
class BackendMode(StrEnum):
"""Supported rendering backends for a video call."""
EMBEDDED = "embedded"
BROWSER = "browser"
@classmethod
def parse(cls, value: BackendMode | str) -> BackendMode:
if isinstance(value, cls):
return value
try:
return cls(str(value).strip().lower())
except ValueError as exc:
raise VideoTicketError("backend mode must be 'embedded' or 'browser'") from exc
def require_supported_backend(value: BackendMode | str) -> BackendMode:
"""Reject browser launch until a server-issued one-time handoff exists."""
mode = BackendMode.parse(value)
if mode is BackendMode.BROWSER:
raise VideoTicketError(
"browser video mode is disabled until a server-issued one-time handoff is available"
)
return mode
Identifier = int | str
def _identifier(value: Any, field_name: str) -> Identifier:
if isinstance(value, bool) or value is None:
raise VideoTicketError(f"{field_name} must be a non-empty identifier")
if isinstance(value, int):
if value <= 0:
raise VideoTicketError(f"{field_name} must be a positive identifier")
return value
if isinstance(value, str):
cleaned = value.strip()
if not cleaned:
raise VideoTicketError(f"{field_name} must be a non-empty identifier")
return cleaned
raise VideoTicketError(f"{field_name} must be a string or integer")
def _non_empty_string(value: Any, field_name: str) -> str:
if not isinstance(value, str) or not value.strip():
raise VideoTicketError(f"{field_name} must be a non-empty string")
return value.strip()
def _sdk_app_id(value: Any, field_name: str = "SDKAppID") -> int:
if isinstance(value, bool):
raise VideoTicketError(f"{field_name} must be a positive integer")
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise VideoTicketError(f"{field_name} must be a positive integer") from exc
if parsed <= 0 or str(value).strip() != str(parsed):
raise VideoTicketError(f"{field_name} must be a positive integer")
return parsed
def _normalized_key(key: Any) -> str:
return "".join(character for character in str(key).lower() if character.isalnum())
_FORBIDDEN_SECRET_KEYS = {"sdksecret", "sdksecretkey", "secretkey"}
def _reject_server_secrets(payload: Mapping[str, Any]) -> None:
for key in payload:
if _normalized_key(key) in _FORBIDDEN_SECRET_KEYS:
raise VideoTicketError("backend ticket contains forbidden server-side secret material")
def _contains_ticket_fields(payload: Mapping[str, Any]) -> bool:
keys = {_normalized_key(key) for key in payload}
return bool(keys & {"sdkappid", "userid", "usersig", "targetuserid", "patientuserid"})
def _ticket_payload(ticket: Mapping[str, Any]) -> Mapping[str, Any]:
_reject_server_secrets(ticket)
if _contains_ticket_fields(ticket):
return ticket
for envelope_key in ("data", "result", "ticket"):
nested = ticket.get(envelope_key)
if isinstance(nested, Mapping):
_reject_server_secrets(nested)
if _contains_ticket_fields(nested):
return nested
return ticket
def _ticket_mapping(ticket: Any) -> Mapping[str, Any]:
"""Adapt the repository's CallTicket model without importing core models."""
if isinstance(ticket, Mapping):
return ticket
raw = getattr(ticket, "raw", None)
if isinstance(raw, Mapping):
_reject_server_secrets(raw)
attribute_aliases = {
"sdkAppId": "sdk_app_id",
"userId": "user_id",
"userSig": "user_sig",
"patientUserId": "patient_user_id",
"diagnosisId": "diagnosis_id",
"patientId": "patient_id",
}
adapted = {
json_name: getattr(ticket, attribute_name)
for json_name, attribute_name in attribute_aliases.items()
if hasattr(ticket, attribute_name)
}
if adapted:
return adapted
raise VideoTicketError("backend ticket must be a mapping or call-ticket object")
def _read_aliases(
payload: Mapping[str, Any],
aliases: tuple[str, ...],
field_name: str,
converter: Callable[[Any, str], Any],
*,
required: bool = True,
) -> Any:
converted: list[Any] = []
for alias in aliases:
if alias in payload and payload[alias] is not None:
converted.append(converter(payload[alias], field_name))
if not converted:
if required:
raise VideoTicketError(f"backend ticket is missing {field_name}")
return None
if any(value != converted[0] and str(value) != str(converted[0]) for value in converted[1:]):
raise VideoTicketError(f"backend ticket has conflicting {field_name} aliases")
return converted[0]
def _merge_identifier(
payload_value: Identifier | None,
explicit_value: Any,
field_name: str,
) -> Identifier:
if explicit_value is None:
if payload_value is None:
raise VideoTicketError(f"backend ticket is missing {field_name}")
return payload_value
normalized = _identifier(explicit_value, field_name)
if (
payload_value is not None
and payload_value != normalized
and str(payload_value) != str(normalized)
):
raise VideoTicketError(f"backend ticket conflicts with requested {field_name}")
return normalized
@dataclass(frozen=True, slots=True)
class VideoCallRequest:
"""Validated data required to start one doctor-to-patient video call.
``user_sig`` is excluded from ``repr``. Use :meth:`safe_log_context` for
structured logs; never serialize the dataclass itself into diagnostics.
"""
sdk_app_id: int
user_id: str
user_sig: str = field(repr=False)
target_user_id: str
diagnosis_id: Identifier
patient_id: Identifier | None = None
backend_mode: BackendMode = BackendMode.EMBEDDED
def __post_init__(self) -> None:
object.__setattr__(self, "sdk_app_id", _sdk_app_id(self.sdk_app_id))
object.__setattr__(self, "user_id", _non_empty_string(self.user_id, "userID"))
object.__setattr__(self, "user_sig", _non_empty_string(self.user_sig, "userSig"))
object.__setattr__(
self,
"target_user_id",
_non_empty_string(self.target_user_id, "targetUserId"),
)
object.__setattr__(
self,
"diagnosis_id",
_identifier(self.diagnosis_id, "diagnosisId"),
)
if self.patient_id is not None:
object.__setattr__(
self,
"patient_id",
_identifier(self.patient_id, "patientId"),
)
object.__setattr__(self, "backend_mode", BackendMode.parse(self.backend_mode))
@classmethod
def from_backend_ticket(
cls,
ticket: Any,
*,
diagnosis_id: Any = None,
patient_id: Any = None,
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
) -> VideoCallRequest:
return normalize_backend_ticket(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
backend_mode=backend_mode,
)
def to_web_config(self) -> dict[str, Any]:
"""Return the canonical JavaScript bridge payload.
The returned mapping contains the short-lived credential and therefore
must only be passed in memory to the trusted companion page.
"""
return {
"SDKAppID": self.sdk_app_id,
"userID": self.user_id,
"userSig": self.user_sig,
"targetUserId": self.target_user_id,
"diagnosisId": self.diagnosis_id,
}
def safe_log_context(self) -> dict[str, Any]:
"""Return non-secret call metadata suitable for structured logging."""
return {
"diagnosis_id": self.diagnosis_id,
"patient_id": self.patient_id,
"backend_mode": self.backend_mode.value,
}
def normalize_backend_ticket(
ticket: Any,
*,
diagnosis_id: Any = None,
patient_id: Any = None,
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
) -> VideoCallRequest:
"""Normalize backend camel-case aliases into a validated call request."""
payload = _ticket_payload(_ticket_mapping(ticket))
payload_diagnosis = _read_aliases(
payload,
("diagnosisId", "diagnosis_id"),
"diagnosisId",
_identifier,
required=False,
)
payload_patient = _read_aliases(
payload,
("patientId", "patient_id"),
"patientId",
_identifier,
required=False,
)
normalized_diagnosis = _merge_identifier(
payload_diagnosis,
diagnosis_id,
"diagnosisId",
)
if patient_id is not None:
normalized_patient = _merge_identifier(payload_patient, patient_id, "patientId")
else:
normalized_patient = payload_patient
return VideoCallRequest(
sdk_app_id=_read_aliases(
payload,
("SDKAppID", "sdkAppId", "sdkAppID"),
"SDKAppID",
_sdk_app_id,
),
user_id=_read_aliases(
payload,
("userID", "userId"),
"userID",
_non_empty_string,
),
user_sig=_read_aliases(
payload,
("userSig", "user_sig"),
"userSig",
_non_empty_string,
),
target_user_id=_read_aliases(
payload,
("targetUserId", "patientUserId"),
"targetUserId",
_non_empty_string,
),
diagnosis_id=normalized_diagnosis,
patient_id=normalized_patient,
backend_mode=BackendMode.parse(backend_mode),
)
@dataclass(slots=True)
class VideoCallLauncher:
"""Small composition root that defers the optional Qt import until launch."""
repository: Any
backend_mode: BackendMode | str = BackendMode.EMBEDDED
local_dist: str | Path | None = None
remote_url: str | None = None
logger: Any = None
browser_opener: Callable[[str], bool] | None = None
def prepare(
self,
ticket: Any,
*,
diagnosis_id: Any = None,
patient_id: Any = None,
) -> VideoCallRequest:
require_supported_backend(self.backend_mode)
return normalize_backend_ticket(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
backend_mode=self.backend_mode,
)
def launch(
self,
ticket: Any,
*,
diagnosis_id: Any = None,
patient_id: Any = None,
) -> Any:
request = self.prepare(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
)
from .window import open_video_call
return open_video_call(
request,
repository=self.repository,
local_dist=self.local_dist,
remote_url=self.remote_url,
logger=self.logger,
browser_opener=self.browser_opener,
)
def launch_video_call(
ticket: Any,
*,
repository: Any,
diagnosis_id: Any = None,
patient_id: Any = None,
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
local_dist: str | Path | None = None,
remote_url: str | None = None,
logger: Any = None,
browser_opener: Callable[[str], bool] | None = None,
) -> Any:
"""Normalize a ticket and open a call with the requested backend."""
return VideoCallLauncher(
repository=repository,
backend_mode=backend_mode,
local_dist=local_dist,
remote_url=remote_url,
logger=logger,
browser_opener=browser_opener,
).launch(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
)
@@ -0,0 +1,307 @@
"""Ordered, non-blocking backend lifecycle writes for one video call.
The repository uses synchronous HTTP. A dedicated daemon worker keeps
``start_call -> bind_call_room -> end_call`` ordered without ever blocking the
Qt GUI thread. Only non-secret call metadata is logged.
"""
from __future__ import annotations
import asyncio
import inspect
import logging
import queue
import threading
from collections.abc import Callable, Mapping
from concurrent.futures import Future
from dataclasses import dataclass
from typing import Any, TypeVar
from .launcher import VideoCallRequest
ResultT = TypeVar("ResultT")
def _settled_future(value: ResultT) -> Future[ResultT]:
future: Future[ResultT] = Future()
future.set_result(value)
return future
def _resolve_result(result: Any) -> Any:
if inspect.isawaitable(result):
return asyncio.run(result)
return result
def _call_repository_method(method: Callable[..., Any], payload: Mapping[str, Any]) -> Any:
"""Invoke common repository signatures with a non-secret payload only."""
try:
signature = inspect.signature(method)
except (TypeError, ValueError):
return _resolve_result(method(**payload))
parameters = list(signature.parameters.values())
if any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters):
return _resolve_result(method(**payload))
keyword_names = {
parameter.name
for parameter in parameters
if parameter.kind
in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
}
accepted = {key: value for key, value in payload.items() if key in keyword_names}
required = [
parameter
for parameter in parameters
if parameter.default is inspect.Parameter.empty
and parameter.kind
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
]
if len(parameters) == 1 and not accepted:
result = method(dict(payload))
elif any(parameter.kind is inspect.Parameter.POSITIONAL_ONLY for parameter in required):
missing = [parameter.name for parameter in required if parameter.name not in payload]
if missing:
raise TypeError("repository method requires unsupported positional parameters")
ordered = [payload[parameter.name] for parameter in required]
result = method(*ordered, **accepted)
else:
result = method(**accepted)
return _resolve_result(result)
@dataclass(slots=True)
class _WorkItem:
operation: str
callback: Callable[[], Any]
future: Future[Any]
class _OrderedDaemonWorker:
"""A minimal FIFO executor with timeout-aware idle observation."""
def __init__(self, logger: logging.Logger, log_context: Mapping[str, Any]) -> None:
self._logger = logger
self._log_context = dict(log_context)
self._queue: queue.Queue[_WorkItem | None] = queue.Queue()
self._lock = threading.Lock()
self._idle = threading.Event()
self._idle.set()
self._pending = 0
self._stopping = False
self._thread = threading.Thread(
target=self._run,
name="video-call-lifecycle",
daemon=True,
)
self._thread.start()
@property
def is_daemon(self) -> bool:
return self._thread.daemon
def submit(self, operation: str, callback: Callable[[], ResultT]) -> Future[ResultT]:
with self._lock:
if self._stopping:
raise RuntimeError("video lifecycle worker is stopping")
future: Future[ResultT] = Future()
self._pending += 1
self._idle.clear()
self._queue.put(_WorkItem(operation, callback, future))
return future
def stop_when_idle(self) -> None:
with self._lock:
if self._stopping:
return
self._stopping = True
self._queue.put(None)
def wait(self, timeout: float = 0.25) -> bool:
bounded = max(0.0, min(float(timeout), 5.0))
return self._idle.wait(bounded)
def _run(self) -> None:
while True:
item = self._queue.get()
if item is None:
self._queue.task_done()
return
try:
if item.future.set_running_or_notify_cancel():
try:
item.future.set_result(item.callback())
except BaseException as error:
item.future.set_exception(error)
self._logger.error(
"video lifecycle operation failed",
extra={
"video_call": self._log_context,
"operation": item.operation,
"error_type": type(error).__name__,
},
)
finally:
with self._lock:
self._pending -= 1
if self._pending == 0:
self._idle.set()
self._queue.task_done()
class OrderedCallLifecycle:
"""Idempotent lifecycle state machine backed by one FIFO daemon worker."""
def __init__(
self,
request: VideoCallRequest,
repository: Any,
logger: logging.Logger,
) -> None:
if repository is None:
raise ValueError("a video call repository is required")
self.request = request
self.repository = repository
self.logger = logger
self.started = False
self.ended = False
self.bound_room_id: str | None = None
self._claimed_room_id: str | None = None
self._start_future: Future[bool] | None = None
self._bind_future: Future[bool] | None = None
self._end_future: Future[bool] | None = None
self._lock = threading.RLock()
self._worker = _OrderedDaemonWorker(logger, request.safe_log_context())
@property
def worker_is_daemon(self) -> bool:
return self._worker.is_daemon
def start(self) -> Future[bool]:
with self._lock:
if self._start_future is not None:
return self._start_future
method = getattr(self.repository, "start_call", None)
if not callable(method):
raise ValueError("video repository does not implement start_call")
payload: dict[str, Any] = {
"diagnosis_id": self.request.diagnosis_id,
"call_type": 2,
}
if self.request.patient_id is not None:
payload["patient_id"] = self.request.patient_id
def operation() -> bool:
_call_repository_method(method, payload)
with self._lock:
self.started = True
self.logger.info(
"video call record started",
extra={"video_call": self.request.safe_log_context()},
)
return True
self._start_future = self._worker.submit("start", operation)
return self._start_future
def bind_room(self, room_id: Any) -> Future[bool]:
cleaned = str(room_id or "").strip()
if not cleaned or cleaned == "0":
return _settled_future(False)
with self._lock:
if self._end_future is not None:
return _settled_future(False)
if self.bound_room_id:
if self.bound_room_id != cleaned:
self.logger.warning(
"ignoring a changed TRTC room identifier",
extra={"video_call": self.request.safe_log_context()},
)
return _settled_future(self.bound_room_id == cleaned)
if self._claimed_room_id:
if self._claimed_room_id != cleaned:
self.logger.warning(
"ignoring a changed TRTC room identifier",
extra={"video_call": self.request.safe_log_context()},
)
return _settled_future(False)
return self._bind_future or _settled_future(False)
if self._start_future is None:
self.start()
self._claimed_room_id = cleaned
method = getattr(self.repository, "bind_call_room", None)
def operation() -> bool:
with self._lock:
started = self.started
if not started:
return False
if not callable(method):
self.logger.warning(
"video repository does not implement bind_call_room",
extra={"video_call": self.request.safe_log_context()},
)
return False
_call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id, "room_id": cleaned},
)
with self._lock:
self.bound_room_id = cleaned
self.logger.info(
"TRTC room bound to video call record",
extra={"video_call": self.request.safe_log_context()},
)
return True
self._bind_future = self._worker.submit("bind", operation)
return self._bind_future
def end(self, reason: str) -> Future[bool]:
with self._lock:
if self._end_future is not None:
return self._end_future
method = getattr(self.repository, "end_call", None)
def operation() -> bool:
with self._lock:
started = self.started
if not started:
with self._lock:
self.ended = True
return False
if not callable(method):
raise ValueError("video repository does not implement end_call")
_call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id},
)
with self._lock:
self.ended = True
self.logger.info(
"video call record ended",
extra={
"video_call": {
**self.request.safe_log_context(),
"reason": str(reason)[:80],
}
},
)
return True
self._end_future = self._worker.submit("end", operation)
self._end_future.add_done_callback(lambda _future: self._worker.stop_when_idle())
return self._end_future
def wait(self, timeout: float = 0.25) -> bool:
"""Wait for queued writes for at most five seconds; never waits indefinitely."""
return self._worker.wait(timeout)
__all__ = ["OrderedCallLifecycle"]
@@ -0,0 +1,109 @@
"""Pure-Python trust policy for the embedded video companion document."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import unquote, urlsplit
from urllib.request import url2pathname
class TrustedDocumentError(ValueError):
"""Raised when a companion location cannot form a safe allowlist."""
def _normalized_host(host: str | None) -> str:
if not host:
return ""
try:
return host.encode("idna").decode("ascii").lower()
except UnicodeError:
return host.lower()
def _normalized_file_path(value: str) -> str | None:
parsed = urlsplit(value)
if parsed.scheme.lower() != "file" or parsed.netloc not in {"", "localhost"}:
return None
path = Path(url2pathname(unquote(parsed.path))).resolve()
rendered = str(path)
return rendered.casefold() if os.name == "nt" else rendered
@dataclass(frozen=True, slots=True)
class TrustedDocumentPolicy:
"""Exact main-document allowlist plus origin matching for media grants."""
scheme: str
host: str
port: int | None
path: str
query: str
local_path: str | None = None
@classmethod
def from_url(cls, url: str, *, is_local: bool) -> TrustedDocumentPolicy:
parsed = urlsplit(url)
scheme = parsed.scheme.lower()
if is_local:
local_path = _normalized_file_path(url)
if local_path is None:
raise TrustedDocumentError("local video companion must be a file URL")
return cls("file", "", None, parsed.path, parsed.query, local_path)
if scheme != "https" or not parsed.hostname:
raise TrustedDocumentError("remote embedded video companion must use HTTPS")
if parsed.username or parsed.password:
raise TrustedDocumentError("remote embedded video companion must not use credentials")
try:
port = parsed.port or 443
except ValueError as error:
raise TrustedDocumentError(
"remote embedded video companion has an invalid port"
) from error
return cls(
"https",
_normalized_host(parsed.hostname),
port,
parsed.path or "/",
parsed.query,
)
def allows_main_document(self, candidate: str) -> bool:
"""Allow only the configured file or HTTPS document, including its query."""
parsed = urlsplit(candidate)
if self.scheme == "file":
return (
parsed.query == self.query and _normalized_file_path(candidate) == self.local_path
)
try:
port = parsed.port or (443 if parsed.scheme.lower() == "https" else None)
except ValueError:
return False
return (
parsed.scheme.lower() == self.scheme
and _normalized_host(parsed.hostname) == self.host
and port == self.port
and (parsed.path or "/") == self.path
and parsed.query == self.query
)
def allows_origin(self, candidate: str) -> bool:
"""Match only the origin that supplied the trusted main document."""
parsed = urlsplit(candidate)
if self.scheme == "file":
return parsed.scheme.lower() == "file" and parsed.netloc in {"", "localhost"}
try:
port = parsed.port or (443 if parsed.scheme.lower() == "https" else None)
except ValueError:
return False
return (
parsed.scheme.lower() == self.scheme
and _normalized_host(parsed.hostname) == self.host
and port == self.port
)
__all__ = ["TrustedDocumentError", "TrustedDocumentPolicy"]
+585
View File
@@ -0,0 +1,585 @@
"""Hardened QtWebEngine host for the video companion.
Browser launch is intentionally disabled until the backend provides a
single-use handoff ticket. PySide6 remains optional at import time, while an
actual call requires an isolated QtWebEngine profile and an active QApplication.
"""
from __future__ import annotations
import json
import logging
import sys
from collections.abc import Callable, Mapping
from concurrent.futures import Future
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlsplit
from .launcher import (
VideoCallRequest,
VideoTicketError,
require_supported_backend,
)
from .lifecycle import OrderedCallLifecycle
from .security import TrustedDocumentError, TrustedDocumentPolicy
try: # Optional by design: core-only builds must still import this module.
from PySide6.QtCore import QObject, Qt, QUrl, Signal, Slot
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import (
QWebEnginePage,
QWebEngineProfile,
QWebEngineSettings,
)
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QApplication, QMainWindow
except (ImportError, OSError) as _qt_import_error: # pragma: no cover - no Qt runtime.
QObject = Qt = QUrl = Signal = Slot = None # type: ignore[assignment]
QWebChannel = QWebEnginePage = QWebEngineProfile = None # type: ignore[assignment]
QWebEngineSettings = QWebEngineView = None # type: ignore[assignment]
QApplication = QMainWindow = None # type: ignore[assignment]
_WEBENGINE_IMPORT_ERROR: Exception | None = _qt_import_error
else: # pragma: no cover - requires a GUI runtime.
_WEBENGINE_IMPORT_ERROR = None
WEBENGINE_AVAILABLE = _WEBENGINE_IMPORT_ERROR is None
_LOGGER = logging.getLogger(__name__)
_SENSITIVE_QUERY_KEYS = {"usersig", "sdksecret", "sdksecretkey", "secretkey"}
class VideoWindowError(RuntimeError):
"""Raised when the trusted embedded companion cannot be opened."""
@dataclass(frozen=True, slots=True)
class CompanionLocation:
url: str
is_local: bool
def _validate_remote_url(value: str) -> str:
parsed = urlsplit(value)
if parsed.scheme.lower() != "https" or not parsed.hostname:
raise VideoWindowError("remote video companion URL must use HTTPS")
if parsed.username or parsed.password:
raise VideoWindowError("remote video companion URL must not contain credentials")
url_parameter_keys = {
"".join(character for character in key.lower() if character.isalnum())
for key, _ in (*parse_qsl(parsed.query), *parse_qsl(parsed.fragment))
}
if url_parameter_keys & _SENSITIVE_QUERY_KEYS:
raise VideoWindowError("remote video companion URL must not contain RTC credentials")
return value
def _candidate_index(local_dist: str | Path) -> Path:
candidate = Path(local_dist).expanduser().resolve()
return candidate if candidate.name.lower() == "index.html" else candidate / "index.html"
def _default_local_indexes() -> tuple[Path, ...]:
candidates: list[Path] = []
bundle_root = getattr(sys, "_MEIPASS", None)
if bundle_root:
candidates.append(Path(bundle_root) / "video_companion_dist" / "index.html")
project_root = Path(__file__).resolve().parents[3]
candidates.append(project_root / "video_companion" / "dist" / "index.html")
return tuple(candidates)
def resolve_companion_location(
*,
local_dist: str | Path | None = None,
remote_url: str | None = None,
) -> CompanionLocation:
"""Resolve the trusted document used inside QtWebEngine."""
indexes = (
(_candidate_index(local_dist),) if local_dist is not None else _default_local_indexes()
)
for index in indexes:
if index.is_file():
return CompanionLocation(index.as_uri(), is_local=True)
if remote_url:
return CompanionLocation(_validate_remote_url(remote_url), is_local=False)
raise VideoWindowError(
"video companion is unavailable: build video_companion/dist or configure an HTTPS URL"
)
def webengine_unavailable_reason() -> str | None:
"""Return a non-sensitive diagnostic reason without importing Qt again."""
if _WEBENGINE_IMPORT_ERROR is None:
return None
return f"{type(_WEBENGINE_IMPORT_ERROR).__name__}: QtWebEngine is not installed"
if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration test.
class _RestrictedWebEnginePage(QWebEnginePage): # type: ignore[misc, valid-type]
def __init__(
self,
profile: Any,
policy: TrustedDocumentPolicy,
logger: logging.Logger,
parent: Any,
) -> None:
super().__init__(profile, parent)
self._policy = policy
self._logger = logger
self._shutting_down = False
def begin_shutdown(self) -> None:
self._shutting_down = True
def acceptNavigationRequest(
self,
url: Any,
navigation_type: Any,
is_main_frame: bool,
) -> bool:
del navigation_type
if not is_main_frame:
return True
rendered = url.toString()
if self._shutting_down and rendered == "about:blank":
return True
if self._policy.allows_main_document(rendered):
return True
self._logger.warning(
"blocked video companion main-document navigation",
extra={
"target_scheme": url.scheme(),
"target_host": url.host(),
},
)
return False
def createWindow(self, window_type: Any) -> Any:
del window_type
self._logger.warning("blocked video companion popup window")
return None
class _QtVideoBridge(QObject): # type: ignore[misc, valid-type]
def __init__(self, callback: Callable[[Mapping[str, Any]], None]) -> None:
super().__init__()
self._callback = callback
@Slot(str) # type: ignore[misc]
def notify(self, payload: str) -> None:
if not isinstance(payload, str) or len(payload) > 16_384:
return
try:
message = json.loads(payload)
except (TypeError, ValueError):
return
if isinstance(message, Mapping) and message.get("source") == "doctor-call":
self._callback(message)
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
status_changed = Signal(str) # type: ignore[misc]
call_ended = Signal(str) # type: ignore[misc]
call_error = Signal(str) # type: ignore[misc]
_start_completed = Signal(bool) # type: ignore[misc]
def __init__(
self,
request: VideoCallRequest,
location: CompanionLocation,
lifecycle: OrderedCallLifecycle,
*,
logger: logging.Logger,
) -> None:
super().__init__()
self.request = request
self.location = location
self.lifecycle = lifecycle
self.logger = logger
try:
self._policy = TrustedDocumentPolicy.from_url(
location.url,
is_local=location.is_local,
)
except TrustedDocumentError as error:
raise VideoWindowError(str(error)) from error
self._injected = False
self._media_active = False
self._closing = False
self._companion_ended = False
self._released = False
self._close_reason = "window-closed"
self._legacy_grants: list[tuple[Any, Any]] = []
self._permission_grants: list[Any] = []
self.setWindowTitle("视频面诊")
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
self.resize(1120, 760)
self.setMinimumSize(760, 520)
self._profile = QWebEngineProfile(self)
if not self._profile.isOffTheRecord():
raise VideoWindowError("video WebEngine profile must be off-the-record")
profile_policy = QWebEngineProfile.PersistentCookiesPolicy
cache_type = QWebEngineProfile.HttpCacheType
self._profile.setPersistentCookiesPolicy(profile_policy.NoPersistentCookies)
self._profile.setHttpCacheType(cache_type.MemoryHttpCache)
self._profile.downloadRequested.connect(self._deny_download)
self.web_view = QWebEngineView(self)
self._page = _RestrictedWebEnginePage(
self._profile,
self._policy,
self.logger,
self.web_view,
)
self.web_view.setPage(self._page)
self.setCentralWidget(self.web_view)
self._configure_settings(self._page.settings())
self._bridge = _QtVideoBridge(self._handle_bridge_message)
self._channel = QWebChannel(self._page)
self._channel.registerObject("qtVideoBridge", self._bridge)
self._page.setWebChannel(self._channel)
self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
def _configure_settings(self, settings: Any) -> None:
attributes = getattr(QWebEngineSettings, "WebAttribute", QWebEngineSettings)
values = (
("LocalContentCanAccessRemoteUrls", self.location.is_local),
("PlaybackRequiresUserGesture", False),
("JavascriptCanOpenWindows", False),
("AllowRunningInsecureContent", False),
)
for name, enabled in values:
attribute = getattr(attributes, name, None)
if attribute is not None:
settings.setAttribute(attribute, enabled)
def _connect_permissions(self) -> None:
if hasattr(self._page, "featurePermissionRequested"):
self._page.featurePermissionRequested.connect(self._grant_legacy_media_permission)
if hasattr(self._page, "permissionRequested"):
self._page.permissionRequested.connect(self._grant_media_permission)
def _permission_context_is_trusted(self, origin: Any) -> bool:
if self._closing or self._released or not self._media_active:
return False
if not self._policy.allows_main_document(self._page.url().toString()):
return False
return self._policy.allows_origin(origin.toString())
def _grant_legacy_media_permission(self, origin: Any, feature: Any) -> None:
features = QWebEnginePage.Feature
allowed = {
features.MediaAudioCapture,
features.MediaVideoCapture,
features.MediaAudioVideoCapture,
}
policies = QWebEnginePage.PermissionPolicy
trusted = self._permission_context_is_trusted(origin) and feature in allowed
policy = (
policies.PermissionGrantedByUser if trusted else policies.PermissionDeniedByUser
)
self._page.setFeaturePermission(origin, feature, policy)
if trusted:
self._legacy_grants.append((origin, feature))
def _grant_media_permission(self, permission: Any) -> None:
permission_type = permission.permissionType()
allowed_names = {
"MediaAudioCapture",
"MediaVideoCapture",
"MediaAudioVideoCapture",
}
trusted = (
permission.isValid()
and permission_type.name in allowed_names
and self._permission_context_is_trusted(permission.origin())
)
if trusted:
permission.grant()
self._permission_grants.append(permission)
else:
permission.deny()
def _deny_download(self, download: Any) -> None:
download.cancel()
def _on_load_finished(self, succeeded: bool) -> None:
if self._closing:
return
if not succeeded:
self.logger.error(
"embedded video companion failed to load",
extra={"video_call": self.request.safe_log_context()},
)
self._close_reason = "page-load-failed"
self.close()
return
try:
start_future = self.lifecycle.start()
except Exception:
self.logger.error(
"video call record could not be queued",
extra={"video_call": self.request.safe_log_context()},
)
self._close_reason = "record-start-queue-failed"
self.close()
return
start_future.add_done_callback(self._notify_start_completed)
def _notify_start_completed(self, future: Future[bool]) -> None:
try:
succeeded = bool(future.result())
except Exception:
succeeded = False
with suppress(RuntimeError):
self._start_completed.emit(succeeded)
def _on_lifecycle_started(self, succeeded: bool) -> None:
if self._closing:
return
if not succeeded:
self._close_reason = "record-start-failed"
self.close()
return
self._media_active = True
config_json = json.dumps(
self.request.to_web_config(),
ensure_ascii=True,
separators=(",", ":"),
)
script = f"""
(() => {{
if (!window.doctorCall || typeof window.doctorCall.start !== 'function') {{
return false;
}}
void window.doctorCall.start({config_json}).catch(() => undefined);
return true;
}})()
"""
self._page.runJavaScript(script, self._after_injection)
def _after_injection(self, result: Any) -> None:
self._injected = result is not False
if not self._injected:
self._close_reason = "bridge-api-missing"
self.close()
def _handle_bridge_message(self, message: Mapping[str, Any]) -> None:
if self._closing:
return
event = str(message.get("event", ""))
room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""):
self.lifecycle.bind_room(room_id)
if event == "room":
return
if event == "status":
status = str(message.get("status", "unknown"))[:80]
self.status_changed.emit(status)
if status == "idle":
self._close_from_companion("remote-idle")
elif event == "hangup":
status = str(message.get("status", "ended"))[:80]
self.call_ended.emit(status)
self._close_from_companion("companion-hangup")
elif event == "error":
message_text = str(message.get("message", "视频通话错误"))[:400]
self.call_error.emit(message_text)
self._close_from_companion("companion-error")
def _close_from_companion(self, reason: str) -> None:
self._companion_ended = True
self._close_reason = reason
self.close()
def hangup(self) -> None:
self._close_reason = "desktop-hangup"
self.close()
def _begin_shutdown(self) -> None:
if self._closing:
return
self._closing = True
self._media_active = False
if self._injected and not self._companion_ended and not self._released:
self._page.runJavaScript(
"void window.doctorCall?.hangup?.().catch(() => undefined)"
)
self.lifecycle.end(self._close_reason)
self._release_webengine()
def _release_webengine(self) -> None:
if self._released:
return
self._released = True
policies = QWebEnginePage.PermissionPolicy
for origin, feature in self._legacy_grants:
with suppress(RuntimeError):
self._page.setFeaturePermission(
origin,
feature,
policies.PermissionDeniedByUser,
)
self._legacy_grants.clear()
for permission in self._permission_grants:
try:
if permission.isValid():
permission.reset()
except RuntimeError:
pass
self._permission_grants.clear()
try:
self._channel.deregisterObject(self._bridge)
self._page.setWebChannel(None)
except RuntimeError:
pass
try:
self._profile.cookieStore().deleteAllCookies()
self._profile.clearHttpCache()
self._profile.clearAllVisitedLinks()
except RuntimeError:
pass
try:
self._page.begin_shutdown()
self._page.setUrl(QUrl("about:blank"))
except RuntimeError:
pass
view = self.takeCentralWidget()
if view is not None:
view.close()
view.deleteLater()
self._page.deleteLater()
self._profile.deleteLater()
def closeEvent(self, event: Any) -> None:
self._begin_shutdown()
event.accept()
else:
_EmbeddedVideoWindow = None # type: ignore[assignment, misc]
class VideoCallWindow:
"""Facade for the only currently supported backend: embedded QtWebEngine."""
def __init__(
self,
request: VideoCallRequest,
*,
repository: Any,
local_dist: str | Path | None = None,
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
) -> None:
del browser_opener # Reserved for a future authenticated handoff implementation.
try:
self.backend_mode = require_supported_backend(request.backend_mode)
except VideoTicketError as error:
raise VideoWindowError(str(error)) from error
if not WEBENGINE_AVAILABLE:
raise VideoWindowError(
"embedded video is unavailable and automatic browser fallback is disabled"
)
if QApplication is None or QApplication.instance() is None:
raise VideoWindowError("embedded video requires an active QApplication")
self.request = request
self.logger = logger or _LOGGER
self.location = resolve_companion_location(
local_dist=local_dist,
remote_url=remote_url,
)
self.lifecycle = OrderedCallLifecycle(request, repository, self.logger)
self._session: Any = None
@property
def qt_window(self) -> Any:
return self._session
def open(self) -> VideoCallWindow:
try:
self._session = _EmbeddedVideoWindow(
self.request,
self.location,
self.lifecycle,
logger=self.logger,
)
except Exception:
self.lifecycle.end("window-open-failed")
raise
self._session.show()
self._session.raise_()
self._session.activateWindow()
return self
show = open
def hangup(self) -> None:
if self._session is not None:
self._session.hangup()
else:
self.lifecycle.end("unopened-session")
def close(self) -> None:
if self._session is not None:
self._session.close()
else:
self.lifecycle.end("unopened-session")
def wait_for_lifecycle(self, timeout: float = 0.25) -> bool:
"""Wait briefly for ordered backend writes; timeout is capped at five seconds."""
return self.lifecycle.wait(timeout)
wait = wait_for_lifecycle
def open_video_call(
request: VideoCallRequest,
*,
repository: Any,
local_dist: str | Path | None = None,
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
) -> VideoCallWindow:
"""Create and immediately open a trusted embedded video window."""
if not isinstance(request, VideoCallRequest):
raise VideoTicketError("request must be a VideoCallRequest")
return VideoCallWindow(
request,
repository=repository,
local_dist=local_dist,
remote_url=remote_url,
logger=logger,
browser_opener=browser_opener,
).open()
__all__ = [
"CompanionLocation",
"TrustedDocumentPolicy",
"VideoCallWindow",
"VideoWindowError",
"WEBENGINE_AVAILABLE",
"open_video_call",
"resolve_companion_location",
"webengine_unavailable_reason",
]