"""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", ]