更新
This commit is contained in:
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user