1362 lines
54 KiB
Python
1362 lines
54 KiB
Python
"""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 base64
|
|
import binascii
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
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 ..services.local_audio_queue import (
|
|
LocalAudioQueueStore,
|
|
LocalAudioUploadManager,
|
|
get_local_audio_upload_manager,
|
|
)
|
|
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, QTimer, 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 = QTimer = 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
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _LocalAudioCapture:
|
|
record_id: int
|
|
session_id: str
|
|
mime_type: str
|
|
path: Path
|
|
handle: Any
|
|
lifecycle: OrderedCallLifecycle
|
|
next_sequence: int = 0
|
|
bytes_written: int = 0
|
|
|
|
|
|
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)
|
|
|
|
@Slot(str) # type: ignore[misc]
|
|
def saveScreenshot(self, data_url: str) -> None: # noqa: N802 - Qt bridge API
|
|
"""Forward one bounded JPEG data URL to the trusted desktop host."""
|
|
|
|
if not isinstance(data_url, str) or len(data_url) > 14 * 1024 * 1024:
|
|
self._callback(
|
|
{
|
|
"source": "doctor-call",
|
|
"event": "screenshot-invalid",
|
|
"message": "截屏图片过大,无法保存。",
|
|
}
|
|
)
|
|
return
|
|
self._callback(
|
|
{
|
|
"source": "doctor-call",
|
|
"event": "screenshot",
|
|
"dataUrl": data_url,
|
|
}
|
|
)
|
|
|
|
@Slot(str, str) # type: ignore[misc]
|
|
def startLocalAudioRecording( # noqa: N802 - Qt bridge API
|
|
self, session_id: str, mime_type: str
|
|
) -> None:
|
|
self._callback(
|
|
{
|
|
"source": "doctor-call",
|
|
"event": "local-audio-start",
|
|
"sessionId": session_id,
|
|
"mimeType": mime_type,
|
|
}
|
|
)
|
|
|
|
@Slot(str, int, str) # type: ignore[misc]
|
|
def appendLocalAudioChunk( # noqa: N802 - Qt bridge API
|
|
self, session_id: str, sequence: int, encoded: str
|
|
) -> None:
|
|
self._callback(
|
|
{
|
|
"source": "doctor-call",
|
|
"event": "local-audio-chunk",
|
|
"sessionId": session_id,
|
|
"sequence": sequence,
|
|
"data": encoded,
|
|
}
|
|
)
|
|
|
|
@Slot(str, int) # type: ignore[misc]
|
|
def finishLocalAudioRecording( # noqa: N802 - Qt bridge API
|
|
self, session_id: str, total_bytes: int
|
|
) -> None:
|
|
self._callback(
|
|
{
|
|
"source": "doctor-call",
|
|
"event": "local-audio-finish",
|
|
"sessionId": session_id,
|
|
"totalBytes": total_bytes,
|
|
}
|
|
)
|
|
|
|
@Slot(str) # type: ignore[misc]
|
|
def abortLocalAudioRecording(self, session_id: str) -> None: # noqa: N802
|
|
self._callback(
|
|
{
|
|
"source": "doctor-call",
|
|
"event": "local-audio-abort",
|
|
"sessionId": session_id,
|
|
}
|
|
)
|
|
|
|
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]
|
|
_room_completed = Signal(str, bool, str) # type: ignore[misc]
|
|
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
|
|
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
|
|
_local_recording_completed = Signal( # type: ignore[misc]
|
|
str, str, int, bool, str
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
request: VideoCallRequest,
|
|
location: CompanionLocation,
|
|
lifecycle: OrderedCallLifecycle,
|
|
*,
|
|
logger: logging.Logger,
|
|
lifecycle_factory: Callable[[], OrderedCallLifecycle],
|
|
open_im: bool = False,
|
|
patient_name: str = "患者",
|
|
patient_case: Mapping[str, Any] | None = None,
|
|
on_open_diagnosis: Callable[[], None] | None = None,
|
|
) -> None:
|
|
super().__init__()
|
|
self.request = request
|
|
self.location = location
|
|
self.lifecycle = lifecycle
|
|
self._lifecycle_factory = lifecycle_factory
|
|
self._lifecycles = [lifecycle]
|
|
self.logger = logger
|
|
self.open_im = bool(open_im)
|
|
self.patient_name = str(patient_name or "患者").strip() or "患者"
|
|
self.patient_case = dict(patient_case or {})
|
|
self._on_open_diagnosis = on_open_diagnosis
|
|
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._call_cycle_closed = False
|
|
self._start_requested = False
|
|
self._shutdown_requested = False
|
|
self._local_audio_capture: _LocalAudioCapture | None = None
|
|
self._local_audio_store: LocalAudioQueueStore | None = None
|
|
self._local_audio_uploads: LocalAudioUploadManager | None = None
|
|
self._legacy_grants: list[tuple[Any, Any]] = []
|
|
self._permission_grants: list[Any] = []
|
|
self._shutdown_timer = QTimer(self)
|
|
self._shutdown_timer.setSingleShot(True)
|
|
self._shutdown_timer.timeout.connect(self._force_requested_shutdown)
|
|
|
|
self.setWindowTitle(
|
|
f"与 {self.patient_name} IM 问诊" if self.open_im else "视频面诊"
|
|
)
|
|
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._room_completed.connect(self._on_room_completed)
|
|
self._screenshot_completed.connect(self._on_screenshot_completed)
|
|
self._transcription_completed.connect(self._on_transcription_completed)
|
|
self._local_recording_completed.connect(self._on_local_recording_completed)
|
|
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
|
|
|
|
self._media_active = True
|
|
config = {
|
|
**self.request.to_web_config(),
|
|
"patientName": self.patient_name,
|
|
"patientCase": self.patient_case,
|
|
"mode": "chat" if self.open_im else "video",
|
|
}
|
|
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
|
|
script = f"""
|
|
(() => {{
|
|
if (!window.doctorConsultation
|
|
|| typeof window.doctorConsultation.open !== 'function') {{
|
|
return false;
|
|
}}
|
|
void window.doctorConsultation.open({config_json}).catch(() => undefined);
|
|
return true;
|
|
}})()
|
|
"""
|
|
self._page.runJavaScript(script, self._after_injection)
|
|
|
|
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._start_requested = False
|
|
self._call_cycle_closed = True
|
|
self._page.runJavaScript(
|
|
"window.doctorConsultation?.hostCallReady?.(false, "
|
|
'"通话记录创建失败,请稍后重试。");'
|
|
)
|
|
return
|
|
self._page.runJavaScript(
|
|
"window.doctorConsultation?.hostCallReady?.(true, '');"
|
|
)
|
|
|
|
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", ""))
|
|
if event == "open-diagnosis-request":
|
|
if self._on_open_diagnosis is not None:
|
|
QTimer.singleShot(0, self._open_diagnosis_safely)
|
|
return
|
|
if event == "call-start-request":
|
|
self._start_call_cycle()
|
|
return
|
|
if event == "screenshot":
|
|
self._save_screenshot(str(message.get("dataUrl") or ""))
|
|
return
|
|
if event == "screenshot-invalid":
|
|
self._on_screenshot_completed(
|
|
False,
|
|
str(message.get("message") or "截屏图片无效。")[:200],
|
|
)
|
|
return
|
|
if event == "local-audio-start":
|
|
self._start_local_audio_recording(
|
|
str(message.get("sessionId") or ""),
|
|
str(message.get("mimeType") or "audio/webm"),
|
|
)
|
|
return
|
|
if event == "local-audio-chunk":
|
|
self._append_local_audio_chunk(
|
|
str(message.get("sessionId") or ""),
|
|
message.get("sequence"),
|
|
str(message.get("data") or ""),
|
|
)
|
|
return
|
|
if event == "local-audio-finish":
|
|
self._finish_local_audio_recording(
|
|
str(message.get("sessionId") or ""),
|
|
message.get("totalBytes"),
|
|
)
|
|
return
|
|
if event == "local-audio-abort":
|
|
self._abort_local_audio_recording(str(message.get("sessionId") or ""))
|
|
return
|
|
if event == "transcription-start-request":
|
|
self._start_transcription(
|
|
str(message.get("sessionId") or ""),
|
|
str(message.get("language") or "zh-CN"),
|
|
)
|
|
return
|
|
if event == "transcription-segment":
|
|
self._save_transcript_segment(message)
|
|
return
|
|
if event == "transcription-stop":
|
|
self._finish_transcription(
|
|
str(message.get("sessionId") or ""),
|
|
str(message.get("status") or "completed"),
|
|
)
|
|
return
|
|
room_id = message.get("roomId", message.get("room_id"))
|
|
if room_id not in (None, ""):
|
|
clean_room_id = str(room_id).strip()
|
|
future = self.lifecycle.bind_room(clean_room_id)
|
|
future.add_done_callback(
|
|
lambda completed, current_room_id=clean_room_id: (
|
|
self._notify_room_completed(current_room_id, completed)
|
|
)
|
|
)
|
|
if event == "room":
|
|
return
|
|
if event == "status":
|
|
status = str(message.get("status", "unknown"))[:80]
|
|
self.status_changed.emit(status)
|
|
elif event == "hangup":
|
|
status = str(message.get("status", "ended"))[:80]
|
|
self.call_ended.emit(status)
|
|
self.lifecycle.end(f"companion-{status}")
|
|
self._call_cycle_closed = True
|
|
self._start_requested = False
|
|
if not self.open_im or self._shutdown_requested:
|
|
self._close_from_companion("companion-hangup")
|
|
elif event == "error":
|
|
message_text = str(message.get("message", "视频通话错误"))[:400]
|
|
self.call_error.emit(message_text)
|
|
if self._start_requested:
|
|
self.lifecycle.end("companion-error")
|
|
self._call_cycle_closed = True
|
|
self._start_requested = False
|
|
if not self.open_im or self._shutdown_requested:
|
|
self._close_from_companion("companion-error")
|
|
|
|
def _open_diagnosis_safely(self) -> None:
|
|
if self._closing or self._on_open_diagnosis is None:
|
|
return
|
|
try:
|
|
self._on_open_diagnosis()
|
|
except Exception:
|
|
self.logger.exception(
|
|
"diagnosis drawer could not be opened from video companion",
|
|
extra={"video_call": self.request.safe_log_context()},
|
|
)
|
|
|
|
def _notify_room_completed(self, room_id: str, future: Future[bool]) -> None:
|
|
try:
|
|
succeeded = bool(future.result())
|
|
except Exception as error:
|
|
succeeded = False
|
|
message = str(error)[:200] or "腾讯云混流视频录制未启动。"
|
|
else:
|
|
message = (
|
|
"腾讯云混流视频已启动;本机录音将在结束后另行上传 COS。"
|
|
if succeeded
|
|
else "通话房间尚未绑定,云端视频和本机录音无法关联通话记录。"
|
|
)
|
|
with suppress(RuntimeError):
|
|
self._room_completed.emit(room_id, succeeded, message)
|
|
|
|
def _on_room_completed(
|
|
self,
|
|
room_id: str,
|
|
succeeded: bool,
|
|
message: str,
|
|
) -> None:
|
|
if self._closing:
|
|
return
|
|
room_payload = json.dumps(str(room_id)[:160], ensure_ascii=True)
|
|
payload = json.dumps(str(message)[:200], ensure_ascii=True)
|
|
state = "true" if succeeded else "false"
|
|
self._page.runJavaScript(
|
|
"window.doctorConsultation?.roomBindingResult?.("
|
|
f"{room_payload}, {state}, {payload});"
|
|
)
|
|
|
|
def _emit_local_recording_result(
|
|
self,
|
|
operation: str,
|
|
session_id: str,
|
|
sequence: int,
|
|
succeeded: bool,
|
|
message: str,
|
|
) -> None:
|
|
with suppress(RuntimeError):
|
|
self._local_recording_completed.emit(
|
|
operation,
|
|
session_id,
|
|
sequence,
|
|
succeeded,
|
|
str(message)[:200],
|
|
)
|
|
|
|
def _on_local_recording_completed(
|
|
self,
|
|
operation: str,
|
|
session_id: str,
|
|
sequence: int,
|
|
succeeded: bool,
|
|
message: str,
|
|
) -> None:
|
|
if self._closing or self._released:
|
|
return
|
|
self._page.runJavaScript(
|
|
"window.doctorConsultation?.localRecordingResult?.("
|
|
f"{json.dumps(operation)}, {json.dumps(session_id)}, {sequence}, "
|
|
f"{'true' if succeeded else 'false'}, "
|
|
f"{json.dumps(str(message)[:200], ensure_ascii=True)});"
|
|
)
|
|
|
|
def _start_local_audio_recording(self, session_id: str, mime_type: str) -> None:
|
|
cleaned = str(session_id or "").strip()
|
|
clean_mime = str(mime_type or "audio/webm").strip().lower()[:120]
|
|
if not re.fullmatch(r"[A-Za-z0-9_-]{12,64}", cleaned):
|
|
self._emit_local_recording_result(
|
|
"start", cleaned, -1, False, "本地录音会话标识无效。"
|
|
)
|
|
return
|
|
if not clean_mime.startswith(("audio/webm", "audio/ogg")):
|
|
self._emit_local_recording_result(
|
|
"start", cleaned, -1, False, "当前浏览器录音格式不受支持。"
|
|
)
|
|
return
|
|
if self._local_audio_capture is not None:
|
|
existing = self._local_audio_capture.session_id == cleaned
|
|
self._emit_local_recording_result(
|
|
"start",
|
|
cleaned,
|
|
-1,
|
|
existing,
|
|
"本地录音已启动。" if existing else "已有另一条本地录音正在进行。",
|
|
)
|
|
return
|
|
try:
|
|
lifecycle = self.lifecycle
|
|
raw_call_record_id = lifecycle.call_record_id
|
|
call_record_id = (
|
|
int(raw_call_record_id) if raw_call_record_id not in (None, "") else None
|
|
)
|
|
store, uploads = get_local_audio_upload_manager(
|
|
lifecycle.repository,
|
|
self._local_audio_store,
|
|
)
|
|
self._local_audio_store = store
|
|
self._local_audio_uploads = uploads
|
|
record = store.begin_recording(
|
|
session_id=cleaned,
|
|
diagnosis_id=self.request.diagnosis_id,
|
|
mime_type=clean_mime,
|
|
call_record_id=call_record_id,
|
|
room_id=lifecycle.current_room_id or "",
|
|
)
|
|
# The handle intentionally remains open across WebChannel chunks.
|
|
handle = record.file_path.open("w+b")
|
|
except (OSError, RuntimeError, sqlite3.Error) as error:
|
|
self._emit_local_recording_result(
|
|
"start", cleaned, -1, False, str(error)[:200]
|
|
)
|
|
return
|
|
self._local_audio_capture = _LocalAudioCapture(
|
|
record_id=record.id,
|
|
session_id=cleaned,
|
|
mime_type=clean_mime,
|
|
path=record.file_path,
|
|
handle=handle,
|
|
lifecycle=lifecycle,
|
|
)
|
|
self._emit_local_recording_result(
|
|
"start", cleaned, -1, True, "本机语音录音已启动。"
|
|
)
|
|
|
|
def _append_local_audio_chunk(
|
|
self,
|
|
session_id: str,
|
|
sequence_value: Any,
|
|
encoded: str,
|
|
) -> None:
|
|
capture = self._local_audio_capture
|
|
try:
|
|
sequence = int(sequence_value)
|
|
except (TypeError, ValueError):
|
|
sequence = -1
|
|
if capture is None or session_id != capture.session_id:
|
|
self._emit_local_recording_result(
|
|
"chunk", session_id, sequence, False, "本地录音会话标识不匹配。"
|
|
)
|
|
return
|
|
if sequence != capture.next_sequence:
|
|
self._emit_local_recording_result(
|
|
"chunk", session_id, sequence, False, "本地录音分片顺序不连续。"
|
|
)
|
|
return
|
|
if not encoded or len(encoded) > 16_384:
|
|
self._emit_local_recording_result(
|
|
"chunk", session_id, sequence, False, "本地录音分片过大或为空。"
|
|
)
|
|
return
|
|
try:
|
|
content = base64.b64decode(encoded, validate=True)
|
|
except (ValueError, binascii.Error):
|
|
self._emit_local_recording_result(
|
|
"chunk", session_id, sequence, False, "本地录音分片解析失败。"
|
|
)
|
|
return
|
|
if not content or len(content) > 12 * 1024:
|
|
self._emit_local_recording_result(
|
|
"chunk", session_id, sequence, False, "本地录音分片大小无效。"
|
|
)
|
|
return
|
|
if capture.bytes_written + len(content) > 512 * 1024 * 1024:
|
|
self._emit_local_recording_result(
|
|
"chunk", session_id, sequence, False, "本地录音超过 512 MB 限制。"
|
|
)
|
|
self._abort_local_audio_recording(session_id)
|
|
return
|
|
try:
|
|
capture.handle.write(content)
|
|
except OSError as error:
|
|
self._emit_local_recording_result(
|
|
"chunk", session_id, sequence, False, str(error)[:200]
|
|
)
|
|
self._abort_local_audio_recording(session_id)
|
|
return
|
|
capture.bytes_written += len(content)
|
|
capture.next_sequence += 1
|
|
|
|
def _finish_local_audio_recording(
|
|
self, session_id: str, total_bytes_value: Any
|
|
) -> None:
|
|
capture = self._local_audio_capture
|
|
try:
|
|
total_bytes = int(total_bytes_value)
|
|
except (TypeError, ValueError):
|
|
total_bytes = -1
|
|
if capture is None or session_id != capture.session_id:
|
|
self._emit_local_recording_result(
|
|
"finish", session_id, -1, False, "本地录音会话标识不匹配。"
|
|
)
|
|
return
|
|
self._local_audio_capture = None
|
|
try:
|
|
capture.handle.flush()
|
|
os.fsync(capture.handle.fileno())
|
|
capture.handle.close()
|
|
except OSError as error:
|
|
self._mark_local_audio_invalid(capture.record_id, str(error))
|
|
self._emit_local_recording_result(
|
|
"finish", session_id, -1, False, str(error)[:200]
|
|
)
|
|
return
|
|
if total_bytes != capture.bytes_written or total_bytes <= 0:
|
|
self._mark_local_audio_invalid(
|
|
capture.record_id, "本地录音文件不完整。"
|
|
)
|
|
self._emit_local_recording_result(
|
|
"finish", session_id, -1, False, "本地录音文件不完整。"
|
|
)
|
|
return
|
|
if capture.bytes_written < 1024:
|
|
self._mark_local_audio_invalid(
|
|
capture.record_id, "本地录音文件为空或只有容器信息。"
|
|
)
|
|
self._emit_local_recording_result(
|
|
"finish",
|
|
session_id,
|
|
-1,
|
|
False,
|
|
"本地录音文件为空或只有容器信息,已阻止上传。",
|
|
)
|
|
return
|
|
try:
|
|
with capture.path.open("rb") as recording:
|
|
signature = recording.read(4)
|
|
except OSError as error:
|
|
self._mark_local_audio_invalid(capture.record_id, str(error))
|
|
self._emit_local_recording_result(
|
|
"finish", session_id, -1, False, str(error)[:200]
|
|
)
|
|
return
|
|
valid_signature = (
|
|
capture.mime_type.startswith("audio/webm")
|
|
and signature == b"\x1aE\xdf\xa3"
|
|
) or (
|
|
capture.mime_type.startswith("audio/ogg") and signature == b"OggS"
|
|
)
|
|
if not valid_signature:
|
|
self._mark_local_audio_invalid(
|
|
capture.record_id, "本地录音格式校验失败。"
|
|
)
|
|
self._emit_local_recording_result(
|
|
"finish",
|
|
session_id,
|
|
-1,
|
|
False,
|
|
"本地录音格式校验失败,已阻止上传无效文件。",
|
|
)
|
|
return
|
|
store = self._local_audio_store
|
|
uploads = self._local_audio_uploads
|
|
if store is None or uploads is None:
|
|
self._emit_local_recording_result(
|
|
"finish", session_id, -1, False, "本机录音队列尚未初始化。"
|
|
)
|
|
return
|
|
try:
|
|
store.finalize_recording(
|
|
capture.record_id,
|
|
size_bytes=capture.bytes_written,
|
|
)
|
|
except (OSError, RuntimeError, sqlite3.Error) as error:
|
|
self._mark_local_audio_invalid(capture.record_id, str(error))
|
|
self._emit_local_recording_result(
|
|
"finish", session_id, -1, False, str(error)[:200]
|
|
)
|
|
return
|
|
|
|
lifecycle = capture.lifecycle
|
|
|
|
def enqueue_upload(start_result: Future[bool] | None = None) -> None:
|
|
try:
|
|
if start_result is not None and not bool(start_result.result()):
|
|
raise RuntimeError("通话记录创建失败,录音已保存在本机,可稍后重试。")
|
|
raw_call_record_id = lifecycle.call_record_id
|
|
call_record_id = int(raw_call_record_id or 0)
|
|
if call_record_id <= 0:
|
|
raise RuntimeError("未取得通话记录编号,录音已保存在本机,可稍后重试。")
|
|
store.bind_identity(
|
|
capture.record_id,
|
|
call_record_id=call_record_id,
|
|
room_id=lifecycle.current_room_id or "",
|
|
)
|
|
uploads.submit(capture.record_id)
|
|
except Exception as error:
|
|
with suppress(Exception):
|
|
store.update_status(
|
|
capture.record_id,
|
|
"failed",
|
|
str(error)[:1000],
|
|
)
|
|
|
|
if lifecycle.call_record_id:
|
|
enqueue_upload()
|
|
else:
|
|
try:
|
|
lifecycle.start().add_done_callback(enqueue_upload)
|
|
except Exception as error:
|
|
store.update_status(
|
|
capture.record_id,
|
|
"failed",
|
|
str(error)[:1000] or "通话记录创建失败。",
|
|
)
|
|
self._emit_local_recording_result(
|
|
"finish",
|
|
session_id,
|
|
-1,
|
|
True,
|
|
"本地录音已保存,正在后台上传 COS。",
|
|
)
|
|
|
|
def _mark_local_audio_invalid(self, record_id: int, message: str) -> None:
|
|
store = self._local_audio_store
|
|
if store is None:
|
|
return
|
|
with suppress(Exception):
|
|
store.mark_invalid(record_id, message)
|
|
|
|
def _abort_local_audio_recording(self, session_id: str) -> None:
|
|
capture = self._local_audio_capture
|
|
if capture is None or (session_id and capture.session_id != session_id):
|
|
return
|
|
self._local_audio_capture = None
|
|
with suppress(OSError):
|
|
capture.handle.flush()
|
|
capture.handle.close()
|
|
self._mark_local_audio_invalid(
|
|
capture.record_id,
|
|
"本次本地录音未正常结束,文件已保留以便排查。",
|
|
)
|
|
|
|
def _start_call_cycle(self) -> None:
|
|
if self._closing or self._start_requested:
|
|
return
|
|
if self._call_cycle_closed:
|
|
self.lifecycle = self._lifecycle_factory()
|
|
self._lifecycles.append(self.lifecycle)
|
|
self._call_cycle_closed = False
|
|
self._start_requested = True
|
|
try:
|
|
future = self.lifecycle.start()
|
|
except Exception:
|
|
self._start_requested = False
|
|
self._on_lifecycle_started(False)
|
|
return
|
|
future.add_done_callback(self._notify_start_completed)
|
|
|
|
def _save_screenshot(self, data_url: str) -> None:
|
|
prefix = "data:image/jpeg;base64,"
|
|
if not data_url.startswith(prefix):
|
|
self._on_screenshot_completed(False, "截屏图片格式不正确。")
|
|
return
|
|
try:
|
|
content = base64.b64decode(data_url[len(prefix) :], validate=True)
|
|
except (ValueError, binascii.Error):
|
|
self._on_screenshot_completed(False, "截屏图片解析失败。")
|
|
return
|
|
try:
|
|
future = self.lifecycle.save_screenshot(
|
|
content,
|
|
f"callshot-{self.request.diagnosis_id}.jpg",
|
|
)
|
|
except Exception as error:
|
|
self._on_screenshot_completed(False, str(error)[:200])
|
|
return
|
|
future.add_done_callback(self._notify_screenshot_completed)
|
|
|
|
def _notify_screenshot_completed(self, future: Future[str]) -> None:
|
|
try:
|
|
future.result()
|
|
except Exception as error:
|
|
succeeded = False
|
|
message = str(error)[:200] or "截屏保存失败。"
|
|
else:
|
|
succeeded = True
|
|
message = "截屏已保存到患者信息。"
|
|
with suppress(RuntimeError):
|
|
self._screenshot_completed.emit(succeeded, message)
|
|
|
|
def _on_screenshot_completed(self, succeeded: bool, message: str) -> None:
|
|
payload = json.dumps(str(message)[:200], ensure_ascii=True)
|
|
state = "true" if succeeded else "false"
|
|
self._page.runJavaScript(
|
|
f"window.doctorConsultation?.screenshotResult?.({state}, {payload});"
|
|
)
|
|
|
|
def _notify_transcription_completed(
|
|
self,
|
|
operation: str,
|
|
session_id: str,
|
|
segment_id: str,
|
|
future: Future[bool],
|
|
) -> None:
|
|
try:
|
|
succeeded = bool(future.result())
|
|
except Exception as error:
|
|
succeeded = False
|
|
message = str(error)[:200] or "录音文字保存失败。"
|
|
else:
|
|
message = {
|
|
"start": "录音文字存储已准备。",
|
|
"segment": "",
|
|
"stop": "本次面诊对话文字已保存。",
|
|
}.get(operation, "")
|
|
with suppress(RuntimeError):
|
|
self._transcription_completed.emit(
|
|
operation, session_id, segment_id, succeeded, message
|
|
)
|
|
|
|
def _on_transcription_completed(
|
|
self,
|
|
operation: str,
|
|
session_id: str,
|
|
segment_id: str,
|
|
succeeded: bool,
|
|
message: str,
|
|
) -> None:
|
|
payload = json.dumps(str(message)[:200], ensure_ascii=True)
|
|
state = "true" if succeeded else "false"
|
|
self._page.runJavaScript(
|
|
"window.doctorConsultation?.transcriptionResult?.("
|
|
f"{json.dumps(operation)}, {json.dumps(session_id)}, "
|
|
f"{json.dumps(segment_id)}, {state}, {payload});"
|
|
)
|
|
|
|
def _start_transcription(self, session_id: str, language: str) -> None:
|
|
try:
|
|
future = self.lifecycle.start_transcription(session_id, language=language)
|
|
except Exception as error:
|
|
self._on_transcription_completed(
|
|
"start", session_id, "", False, str(error)[:200]
|
|
)
|
|
return
|
|
future.add_done_callback(
|
|
lambda completed: self._notify_transcription_completed(
|
|
"start", session_id, "", completed
|
|
)
|
|
)
|
|
|
|
def _save_transcript_segment(self, message: Mapping[str, Any]) -> None:
|
|
session_id = str(message.get("sessionId") or "").strip()
|
|
segment = message.get("segment")
|
|
segment_id = (
|
|
str(segment.get("segment_id") or segment.get("segmentId") or "").strip()
|
|
if isinstance(segment, Mapping)
|
|
else ""
|
|
)
|
|
if not isinstance(segment, Mapping):
|
|
self._on_transcription_completed(
|
|
"segment", session_id, segment_id, False, "录音文字片段无效。"
|
|
)
|
|
return
|
|
if session_id != str(self.lifecycle.transcription_session_id or ""):
|
|
self._on_transcription_completed(
|
|
"segment", session_id, segment_id, False, "录音会话标识不匹配。"
|
|
)
|
|
return
|
|
try:
|
|
future = self.lifecycle.save_transcript_segment(segment)
|
|
except Exception as error:
|
|
self._on_transcription_completed(
|
|
"segment", session_id, segment_id, False, str(error)[:200]
|
|
)
|
|
return
|
|
future.add_done_callback(
|
|
lambda completed: self._notify_transcription_completed(
|
|
"segment", session_id, segment_id, completed
|
|
)
|
|
)
|
|
|
|
def _finish_transcription(self, session_id: str, status: str) -> None:
|
|
if session_id.strip() != str(self.lifecycle.transcription_session_id or ""):
|
|
self._on_transcription_completed(
|
|
"stop", session_id, "", False, "录音会话标识不匹配。"
|
|
)
|
|
return
|
|
try:
|
|
future = self.lifecycle.finish_transcription(status=status)
|
|
except Exception as error:
|
|
self._on_transcription_completed(
|
|
"stop", session_id, "", False, str(error)[:200]
|
|
)
|
|
return
|
|
future.add_done_callback(
|
|
lambda completed: self._notify_transcription_completed(
|
|
"stop", session_id, "", completed
|
|
)
|
|
)
|
|
|
|
def _close_from_companion(self, reason: str) -> None:
|
|
self._companion_ended = True
|
|
self._close_reason = reason
|
|
self.close()
|
|
|
|
def hangup(self) -> None:
|
|
self._request_companion_shutdown("desktop-hangup")
|
|
|
|
def _request_companion_shutdown(self, reason: str) -> None:
|
|
"""Let MediaRecorder finish and upload before WebEngine is destroyed."""
|
|
|
|
self._close_reason = reason
|
|
if self._closing or self._released:
|
|
return
|
|
should_wait_for_companion = (
|
|
self._injected
|
|
and self._start_requested
|
|
and not self._call_cycle_closed
|
|
and not self._companion_ended
|
|
)
|
|
if not should_wait_for_companion:
|
|
self.close()
|
|
return
|
|
if self._shutdown_requested:
|
|
return
|
|
self._shutdown_requested = True
|
|
# doctorConsultation.close() stops MediaRecorder, drains every queued
|
|
# WebChannel chunk, waits for the Qt/COS finish acknowledgement, and
|
|
# only then emits hangup. Keeping _closing false here is essential:
|
|
# bridge callbacks are deliberately rejected once final destruction
|
|
# begins.
|
|
self._page.runJavaScript(
|
|
"void window.doctorConsultation?.close?.().catch(() => undefined)"
|
|
)
|
|
self._shutdown_timer.start(190_000)
|
|
|
|
def _force_requested_shutdown(self) -> None:
|
|
"""Bound a failed companion shutdown without racing queued uploads."""
|
|
|
|
if not self._shutdown_requested or self._closing or self._released:
|
|
return
|
|
if self._start_requested and not self._call_cycle_closed:
|
|
# OrderedCallLifecycle places end after any upload that already
|
|
# reached the Qt bridge.
|
|
self.lifecycle.end(f"{self._close_reason}-timeout")
|
|
self._call_cycle_closed = True
|
|
self._start_requested = False
|
|
self._abort_local_audio_recording("")
|
|
self._companion_ended = True
|
|
self.close()
|
|
|
|
def _begin_shutdown(self) -> None:
|
|
if self._closing:
|
|
return
|
|
self._closing = True
|
|
self._media_active = False
|
|
self._shutdown_timer.stop()
|
|
if self._start_requested and not self._call_cycle_closed:
|
|
self.lifecycle.end(self._close_reason)
|
|
self._abort_local_audio_recording("")
|
|
self._release_webengine()
|
|
|
|
def wait_for_lifecycles(self, timeout: float) -> bool:
|
|
return all(lifecycle.wait(timeout) for lifecycle in self._lifecycles)
|
|
|
|
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:
|
|
if (
|
|
self._injected
|
|
and self._start_requested
|
|
and not self._call_cycle_closed
|
|
and not self._companion_ended
|
|
):
|
|
event.ignore()
|
|
self._request_companion_shutdown(self._close_reason)
|
|
return
|
|
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,
|
|
open_im: bool = False,
|
|
patient_name: str = "患者",
|
|
patient_case: Mapping[str, Any] | None = None,
|
|
on_open_diagnosis: Callable[[], None] | 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.repository = repository
|
|
self.open_im = bool(open_im)
|
|
self.patient_name = str(patient_name or "患者").strip() or "患者"
|
|
self.patient_case = dict(patient_case or {})
|
|
self.on_open_diagnosis = on_open_diagnosis
|
|
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._lifecycles = [self.lifecycle]
|
|
self._session: Any = None
|
|
|
|
def _new_lifecycle(self) -> OrderedCallLifecycle:
|
|
lifecycle = OrderedCallLifecycle(self.request, self.repository, self.logger)
|
|
self._lifecycles.append(lifecycle)
|
|
return lifecycle
|
|
|
|
@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,
|
|
lifecycle_factory=self._new_lifecycle,
|
|
open_im=self.open_im,
|
|
patient_name=self.patient_name,
|
|
patient_case=self.patient_case,
|
|
on_open_diagnosis=self.on_open_diagnosis,
|
|
)
|
|
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."""
|
|
|
|
if self._session is not None:
|
|
return self._session.wait_for_lifecycles(timeout)
|
|
return all(lifecycle.wait(timeout) for lifecycle in self._lifecycles)
|
|
|
|
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,
|
|
open_im: bool = False,
|
|
patient_name: str = "患者",
|
|
patient_case: Mapping[str, Any] | None = None,
|
|
on_open_diagnosis: Callable[[], None] | 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_im=open_im,
|
|
patient_name=patient_name,
|
|
patient_case=patient_case,
|
|
on_open_diagnosis=on_open_diagnosis,
|
|
).open()
|
|
|
|
|
|
__all__ = [
|
|
"CompanionLocation",
|
|
"TrustedDocumentPolicy",
|
|
"VideoCallWindow",
|
|
"VideoWindowError",
|
|
"WEBENGINE_AVAILABLE",
|
|
"open_video_call",
|
|
"resolve_companion_location",
|
|
"webengine_unavailable_reason",
|
|
]
|