From 6befab13f760ca3b193a1b1d6ad1478ba5f24295 Mon Sep 17 00:00:00 2001 From: Chen Date: Mon, 11 May 2026 02:52:19 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(sharing):=20=E7=A8=B3=E5=AE=9A=E7=BD=91?= =?UTF-8?q?=E9=A1=B5=E8=A7=82=E7=9C=8B=E7=AB=AF=E5=90=AF=E5=8A=A8=E4=B8=8E?= =?UTF-8?q?=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缩短 WebRTC 发布端建连等待并为首帧等待增加超时重试 - 拆分网页观看端资源并修正低变化画面的诊断口径 - 补充 Relay、WebServer 和服务重启相关测试覆盖 --- .../Bootstrap/VoidDisplayApp.swift | 27 + .../Resources/displayPage.css | 249 ++++ .../Resources/displayPage.html | 1134 +---------------- .../Resources/displayPageMessages.js | 96 ++ .../Resources/displayPageRuntime.js | 861 +++++++++++++ .../Services/WebServiceController.swift | 71 -- .../Web/RelaySessionHub.swift | 65 +- .../Web/WebRTCPublisherSession.swift | 47 +- .../VoidDisplaySharing/Web/WebServer.swift | 43 +- .../SharingEndToEndIntegrationTests.swift | 30 + .../WebServerSocketIntegrationTests.swift | 273 +++- .../Services/WebServiceControllerTests.swift | 26 +- .../Web/RelaySessionHubTests.swift | 51 + 13 files changed, 1692 insertions(+), 1281 deletions(-) create mode 100644 Sources/VoidDisplaySharing/Resources/displayPage.css create mode 100644 Sources/VoidDisplaySharing/Resources/displayPageMessages.js create mode 100644 Sources/VoidDisplaySharing/Resources/displayPageRuntime.js diff --git a/Sources/VoidDisplayApp/Bootstrap/VoidDisplayApp.swift b/Sources/VoidDisplayApp/Bootstrap/VoidDisplayApp.swift index 8fa7d65..1ffde94 100644 --- a/Sources/VoidDisplayApp/Bootstrap/VoidDisplayApp.swift +++ b/Sources/VoidDisplayApp/Bootstrap/VoidDisplayApp.swift @@ -11,6 +11,7 @@ import VoidDisplayFoundation // // +import AppKit import Foundation import SwiftUI @@ -26,6 +27,7 @@ package struct AppEnvironment { } public struct VoidDisplayApplication: App { + @NSApplicationDelegateAdaptor(VoidDisplayApplicationDelegate.self) private var appDelegate @State private var capture: CaptureController @State private var sharing: SharingController @State private var virtualDisplay: VirtualDisplayController @@ -45,6 +47,9 @@ public struct VoidDisplayApplication: App { _navigation = State(initialValue: AppNavigationController()) _feedbackController = State(initialValue: env.feedbackController) observability = env.observability + AppTerminationCleanup.install { + env.sharing.stopWebService() + } } public var body: some Scene { @@ -104,6 +109,28 @@ public struct VoidDisplayApplication: App { } } +@MainActor +private enum AppTerminationCleanup { + private static var handler: (() -> Void)? + + static func install(_ handler: @escaping () -> Void) { + self.handler = handler + } + + static func run() { + guard let handler else { return } + self.handler = nil + handler() + } +} + +@MainActor +private final class VoidDisplayApplicationDelegate: NSObject, NSApplicationDelegate { + func applicationWillTerminate(_: Notification) { + AppTerminationCleanup.run() + } +} + @MainActor package enum AppBootstrap { private static let xCTestConfigurationEnvironmentKey = "XCTestConfigurationFilePath" diff --git a/Sources/VoidDisplaySharing/Resources/displayPage.css b/Sources/VoidDisplaySharing/Resources/displayPage.css new file mode 100644 index 0000000..fdf9306 --- /dev/null +++ b/Sources/VoidDisplaySharing/Resources/displayPage.css @@ -0,0 +1,249 @@ +:root { + color-scheme: dark; + --panel: rgba(255, 255, 255, 0.08); + --text: #f7f9fd; + --muted: rgba(247, 249, 253, 0.65); +} +* { box-sizing: border-box; } +body { + margin: 0; + height: 100vh; + font-family: "SF Pro Display", "Helvetica Neue", sans-serif; + background: + radial-gradient(circle at top left, rgba(143, 211, 255, 0.18), transparent 32%), + radial-gradient(circle at bottom right, rgba(51, 101, 196, 0.18), transparent 38%), + linear-gradient(160deg, #080b11, #0d1728 55%, #060910); + color: var(--text); + display: grid; + place-items: center; + padding: 12px; + overflow: hidden; +} +.shell { + width: 98vw; + height: calc(100vh - 24px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; +} +.hero { + display: grid; + grid-template-columns: minmax(12rem, 1fr) minmax(0, auto); + align-items: center; + gap: 18px; + padding: 18px 22px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 20px; + background: var(--panel); + backdrop-filter: blur(18px); +} +.hero-brand { + min-width: 0; +} +.hero-metadata { + display: flex; + min-width: 0; + align-items: center; + justify-content: flex-end; + justify-self: end; + gap: 18px; +} +.hero-actions { + display: flex; + min-width: 0; + align-items: center; + justify-content: flex-end; +} +.eyebrow { + margin: 0; + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--muted); +} +.controls { + display: flex; + gap: 8px; +} +.control-btn { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 999px; + padding: 8px 12px; + color: var(--text); + background: rgba(255, 255, 255, 0.06); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; +} +.control-btn:hover { + background: rgba(255, 255, 255, 0.12); +} +.stage { + position: relative; + border-radius: 28px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.08); + background: #000; + min-height: 0; + box-shadow: 0 36px 90px rgba(0, 0, 0, 0.45); +} +video { + width: 100%; + height: 100%; + display: block; + object-fit: contain; + background: #000; +} +.video-info { + flex: 0 1 clamp(28rem, 42vw, 42rem); + width: clamp(28rem, 42vw, 42rem); + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; + font-size: 12px; + font-weight: 650; + line-height: 1.3; + opacity: 0.92; + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; +} +.video-info::before { + content: ""; + display: inline-block; + width: 6px; + height: 6px; + margin-right: 8px; + border-radius: 999px; + background: #5ee089; + box-shadow: 0 0 12px rgba(94, 224, 137, 0.5); + vertical-align: 1px; +} +.video-info:empty { + display: none; +} +.video-info:empty::before { + content: none; +} +.overlay { + position: absolute; + z-index: 2; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + background: linear-gradient(180deg, rgba(6, 9, 16, 0.22), rgba(6, 9, 16, 0.72)); + text-align: center; +} +.overlay[hidden] { display: none; } +.loading-spinner { + width: 46px; + height: 46px; + border: 3px solid rgba(255, 255, 255, 0.2); + border-top-color: rgba(255, 255, 255, 0.92); + border-radius: 50%; + animation: loading-spin 860ms linear infinite; +} +@keyframes loading-spin { + to { transform: rotate(360deg); } +} +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 8px; + min-height: 36px; +} +.footnote { + margin: 0; + font-size: 13px; + color: var(--muted); +} +.connection-status { + display: grid; + justify-items: start; + width: clamp(14rem, 24vw, 24rem); + max-width: min(520px, 52vw); + margin-left: auto; + padding: 0; + color: var(--text); + overflow-wrap: anywhere; + text-align: right; +} +.connection-status-title { + width: 100%; + font-size: 12px; + font-weight: 700; + line-height: 1.3; +} +.connection-status-detail { + width: 100%; + margin-top: 2px; + color: var(--muted); + font-size: 12px; + line-height: 1.3; +} +.connection-status-detail[hidden] { display: none; } +@media (max-width: 760px) { + .hero { + grid-template-columns: 1fr; + align-items: flex-start; + } + .hero-brand { + grid-column: 1; + grid-row: 1; + } + .hero-metadata { + grid-column: 1; + grid-row: 2; + width: 100%; + justify-content: flex-end; + flex-wrap: wrap; + } + .video-info { + order: 2; + flex-basis: 100%; + width: 100%; + text-align: right; + } + .footer { + align-items: stretch; + flex-direction: column; + } + .connection-status { + width: 100%; + text-align: right; + } +} +body.mode-native { + height: auto; + min-height: 100vh; + overflow: auto; +} +body.mode-native .shell { + height: auto; + min-height: calc(100vh - 24px); +} +body.mode-native .stage { + overflow: auto; + display: block; +} +body.mode-native video { + width: auto; + height: auto; + max-width: none; + max-height: none; + margin: 0 auto; +} +@media (max-height: 860px) { + .hero { + padding: 12px 16px; + } + .footnote { + font-size: 12px; + } +} diff --git a/Sources/VoidDisplaySharing/Resources/displayPage.html b/Sources/VoidDisplaySharing/Resources/displayPage.html index bf27469..1704f6c 100644 --- a/Sources/VoidDisplaySharing/Resources/displayPage.html +++ b/Sources/VoidDisplaySharing/Resources/displayPage.html @@ -5,183 +5,22 @@ __PAGE_TITLE__
-
+

VoidDisplay Live

-
-
Connecting…
-
- - +
@@ -189,956 +28,25 @@
-
-

Connecting…

-

Preparing the WebRTC stream.

-
+
-

Use `1:1` for original size and `Fullscreen` for immersive view.

+
+

Use `1:1` for original size and `Fullscreen` for immersive view.

+
+
Connecting…
+
Preparing the WebRTC stream.
+
+
+ diff --git a/Sources/VoidDisplaySharing/Resources/displayPageMessages.js b/Sources/VoidDisplaySharing/Resources/displayPageMessages.js new file mode 100644 index 0000000..1befea4 --- /dev/null +++ b/Sources/VoidDisplaySharing/Resources/displayPageMessages.js @@ -0,0 +1,96 @@ +const messages = { + en: { + pageTitle: "Screen Share", + heroEyebrow: "VoidDisplay Live", + statusSignalingConnected: "Signaling connected", + statusConnected: "Connected", + statusLive: "Live", + statusStopped: "Stopped", + statusConnectionError: "Connection error", + statusLiveWithStats: (codec, width, height, fps) => `Live ${codec} ${width}×${height} ${fps}fps`, + statusLiveWithSource: (codec, width, height, fps, sourceWidth, sourceHeight, sourceFps) => + `Live ${codec} ${width}×${height} ${fps}fps, source ${sourceWidth}×${sourceHeight} ${sourceFps}fps`, + statusLiveLowMotionWithSource: (codec, width, height, sourceWidth, sourceHeight, sourceFps) => + `Live ${codec} ${width}×${height}, source ${sourceWidth}×${sourceHeight} ${sourceFps}fps, low-motion screen updates on change`, + statusLiveBelowSource: (codec, width, height, fps, sourceWidth, sourceHeight, sourceFps) => + `Live ${codec} ${width}×${height} ${fps}fps, source ${sourceWidth}×${sourceHeight} ${sourceFps}fps`, + scaleFit: "Fit", + scaleOriginal: "1:1", + fullscreenEnter: "Fullscreen", + fullscreenExit: "Exit Fullscreen", + overlayConnectingTitle: "Connecting…", + overlayConnectingBody: "Preparing the WebRTC signaling channel.", + overlayNegotiatingTitle: "Negotiating…", + overlayNegotiatingBody: "Exchanging offer/answer and ICE candidates.", + overlayLiveBody: "Connected and receiving frames.", + overlayReconnectTitle: "Reconnecting…", + overlayReconnectBody: "The signaling channel dropped. Retrying automatically.", + overlayConnectionLostTitle: "Connection lost", + overlayConnectionLostBody: "Trying to reconnect to the stream.", + overlayWebSocketRequiredTitle: "WebSocket required", + overlayWebSocketRequiredBody: "This browser cannot open signaling transport.", + overlayWebRTCRequiredTitle: "WebRTC required", + overlayWebRTCRequiredBody: "This browser does not support RTCPeerConnection.", + overlayCodecRequiredTitle: "AV1 required", + overlayCodecRequiredBody: "This browser or device did not expose AV1 for WebRTC playback.", + overlayCodecAnswerRequiredBody: "The WebRTC answer did not negotiate AV1 video.", + overlayFirstFrameTimeoutTitle: "Waiting for video", + overlayFirstFrameTimeoutBody: "Timed out waiting for the first video frame. Retrying automatically.", + overlayCodecPendingTitle: "Preparing video codec", + overlayCodecPendingBody: "The source stream is still preparing AV1. Retrying automatically.", + overlayNegotiationFailedTitle: "Negotiation failed", + overlayNegotiationFailedFallback: "Failed to create WebRTC offer.", + overlaySharingStoppedTitle: "Sharing stopped", + overlaySharingStoppedBody: "The source stream is no longer available.", + overlayStreamErrorTitle: "Stream error", + overlayStreamErrorFallback: "Unknown signaling error.", + footnote: "Use `1:1` for original size and `Fullscreen` for immersive view." + }, + zhHans: { + pageTitle: "屏幕共享", + heroEyebrow: "VOIDDISPLAY 实时画面", + statusSignalingConnected: "信令已连接", + statusConnected: "已连接", + statusLive: "直播中", + statusStopped: "已停止", + statusConnectionError: "连接出错", + statusLiveWithStats: (codec, width, height, fps) => `直播中 ${codec} ${width}×${height} ${fps}fps`, + statusLiveWithSource: (codec, width, height, fps, sourceWidth, sourceHeight, sourceFps) => + `直播中 ${codec} ${width}×${height} ${fps}fps,源 ${sourceWidth}×${sourceHeight} ${sourceFps}fps`, + statusLiveLowMotionWithSource: (codec, width, height, sourceWidth, sourceHeight, sourceFps) => + `直播中 ${codec} ${width}×${height},源 ${sourceWidth}×${sourceHeight} ${sourceFps}fps,静态画面按变化更新`, + statusLiveBelowSource: (codec, width, height, fps, sourceWidth, sourceHeight, sourceFps) => + `直播中 ${codec} ${width}×${height} ${fps}fps,源 ${sourceWidth}×${sourceHeight} ${sourceFps}fps`, + scaleFit: "适应", + scaleOriginal: "1:1", + fullscreenEnter: "全屏", + fullscreenExit: "退出全屏", + overlayConnectingTitle: "连接中…", + overlayConnectingBody: "正在准备 WebRTC 信令通道。", + overlayNegotiatingTitle: "协商中…", + overlayNegotiatingBody: "正在交换 offer、answer 和 ICE 候选。", + overlayLiveBody: "已连接并开始接收画面。", + overlayReconnectTitle: "正在重连…", + overlayReconnectBody: "信令通道已断开,正在自动重试。", + overlayConnectionLostTitle: "连接已断开", + overlayConnectionLostBody: "正在尝试重新连接画面流。", + overlayWebSocketRequiredTitle: "需要 WebSocket", + overlayWebSocketRequiredBody: "当前浏览器无法建立信令传输通道。", + overlayWebRTCRequiredTitle: "需要 WebRTC", + overlayWebRTCRequiredBody: "当前浏览器不支持 RTCPeerConnection。", + overlayCodecRequiredTitle: "需要 AV1", + overlayCodecRequiredBody: "当前浏览器或设备未暴露 WebRTC AV1 播放能力。", + overlayCodecAnswerRequiredBody: "WebRTC answer 未协商到 AV1 视频。", + overlayFirstFrameTimeoutTitle: "正在等待画面", + overlayFirstFrameTimeoutBody: "等待首帧超时,正在自动重试。", + overlayCodecPendingTitle: "正在准备视频编码", + overlayCodecPendingBody: "源端仍在准备 AV1,正在自动重试。", + overlayNegotiationFailedTitle: "协商失败", + overlayNegotiationFailedFallback: "创建 WebRTC offer 失败。", + overlaySharingStoppedTitle: "共享已停止", + overlaySharingStoppedBody: "源端画面已不可用。", + overlayStreamErrorTitle: "画面错误", + overlayStreamErrorFallback: "发生未知信令错误。", + footnote: "使用“1:1”查看原始尺寸,使用“全屏”进入沉浸式观看。" + } +}; diff --git a/Sources/VoidDisplaySharing/Resources/displayPageRuntime.js b/Sources/VoidDisplaySharing/Resources/displayPageRuntime.js new file mode 100644 index 0000000..ace56ba --- /dev/null +++ b/Sources/VoidDisplaySharing/Resources/displayPageRuntime.js @@ -0,0 +1,861 @@ +const signalPath = "__SIGNAL_PATH__"; +const bootstrapEl = document.getElementById("voiddisplay-bootstrap"); +const videoInfoEl = document.getElementById("video-info"); +const connectionStatusTitleEl = document.getElementById("connection-status-title"); +const connectionStatusDetailEl = document.getElementById("connection-status-detail"); +const overlayEl = document.getElementById("overlay"); +const heroEyebrowEl = document.getElementById("hero-eyebrow"); +const footnoteEl = document.getElementById("footnote"); +const player = document.getElementById("player"); +const stage = document.querySelector(".stage"); +const scaleModeBtn = document.getElementById("scale-mode-btn"); +const fullscreenBtn = document.getElementById("fullscreen-btn"); + +let socket = null; +let peer = null; +let reconnectIndex = 0; +let reconnectTimer = null; +var terminalStop = false; +var state = "idle"; +let originalScaleEnabled = false; +let peerLifecycleID = 0; +let expectedSourceVideoSpec = null; +const browserStatsStatusIntervalMs = 2000; +const browserStatsState = { + timer: null, + lastBytesReceived: null, + lastFramesDecoded: null, + lastTimestamp: null +}; +const reconnectDelays = [250, 500, 1000, 2000, 4000]; +const firstVideoFrameTimeoutMs = 10000; + +function resolveLocale() { + const preferredLocales = Array.isArray(navigator.languages) && navigator.languages.length > 0 + ? navigator.languages + : [navigator.language || "en"]; + for (const locale of preferredLocales) { + const normalized = String(locale || "").toLowerCase(); + if ( + normalized === "zh-hans" || + normalized === "zh-cn" || + normalized === "zh-sg" || + normalized.startsWith("zh") + ) { + return "zhHans"; + } + } + return "en"; +} + +const locale = resolveLocale(); +const currentMessages = messages[locale] || messages.en; + +function t(key, ...args) { + const value = currentMessages[key]; + if (typeof value === "function") { + return value(...args); + } + return value ?? messages.en[key] ?? ""; +} + +const bootstrap = (() => { + if (!bootstrapEl?.textContent) { + return { iceServers: [] }; + } + try { + const parsed = JSON.parse(bootstrapEl.textContent); + const iceServers = Array.isArray(parsed?.iceServers) ? parsed.iceServers : []; + return { iceServers }; + } catch { + return { iceServers: [] }; + } +})(); +const localOfferIceTimeoutMs = 2000; + +function applyStaticCopy() { + document.title = t("pageTitle"); + if (heroEyebrowEl) { + heroEyebrowEl.textContent = t("heroEyebrow"); + } + if (footnoteEl) { + footnoteEl.textContent = t("footnote"); + } +} + +function setVideoInfo(text) { + if (!videoInfoEl) return; + const normalized = String(text || ""); + videoInfoEl.textContent = normalized; +} + +function setConnectionStatus(title, detail = "") { + if (connectionStatusTitleEl) { + connectionStatusTitleEl.textContent = title; + } + if (connectionStatusDetailEl) { + connectionStatusDetailEl.textContent = detail; + connectionStatusDetailEl.hidden = String(detail || "").length === 0; + } +} + +function transition(nextState) { + state = nextState; +} + +function setLoadingOverlayVisible(visible) { + overlayEl.hidden = !visible; +} + +function setProgressOverlay(title, body) { + setConnectionStatus(title, body); + setLoadingOverlayVisible(true); +} + +function applyScaleMode() { + document.body.classList.toggle("mode-native", originalScaleEnabled); + if (scaleModeBtn) { + scaleModeBtn.textContent = originalScaleEnabled ? t("scaleFit") : t("scaleOriginal"); + } +} + +function syncFullscreenButtonLabel() { + if (!fullscreenBtn) return; + fullscreenBtn.textContent = document.fullscreenElement ? t("fullscreenExit") : t("fullscreenEnter"); +} + +async function toggleFullscreen() { + if (!document.fullscreenEnabled || !stage) return; + try { + if (document.fullscreenElement) { + await document.exitFullscreen(); + } else { + await stage.requestFullscreen(); + } + } catch { + // no-op: button state will remain unchanged + } +} + +scaleModeBtn?.addEventListener("click", () => { + originalScaleEnabled = !originalScaleEnabled; + applyScaleMode(); +}); + +fullscreenBtn?.addEventListener("click", () => { + toggleFullscreen(); +}); + +document.addEventListener("fullscreenchange", syncFullscreenButtonLabel); +applyStaticCopy(); +applyScaleMode(); +syncFullscreenButtonLabel(); + +function closePeer() { + peerLifecycleID += 1; + stopBrowserStatsLoop(); + if (peer) { + peer.ontrack = null; + peer.onicecandidate = null; + peer.onconnectionstatechange = null; + peer.close(); + peer = null; + } + player.srcObject = null; + setVideoInfo(""); +} + +function isSocketConnectingOrOpen(ws) { + if (!ws) return false; + return ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN; +} + +function closeSocketAndClearReference() { + const ws = socket; + if (!ws) return; + socket = null; + try { + ws.close(); + } catch { + // no-op + } +} + +function clearReconnectTimer() { + if (reconnectTimer) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } +} + +function scheduleReconnect(overlayTitle = t("overlayReconnectTitle"), overlayBody = t("overlayReconnectBody")) { + if (terminalStop || state === "stopping" || state === "closed") { + return; + } + if (reconnectTimer) { + return; + } + const delay = reconnectDelays[Math.min(reconnectIndex, reconnectDelays.length - 1)]; + reconnectIndex += 1; + setProgressOverlay(overlayTitle, overlayBody); + transition("handshaking"); + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); +} + +function schedulePeerRetry(overlayTitle, overlayBody) { + if (terminalStop || state === "stopping" || state === "closed") { + return; + } + if (reconnectTimer) { + return; + } + const delay = reconnectDelays[Math.min(reconnectIndex, reconnectDelays.length - 1)]; + reconnectIndex += 1; + setProgressOverlay(overlayTitle, overlayBody); + transition("signalingReady"); + reconnectTimer = window.setTimeout(async () => { + reconnectTimer = null; + if (terminalStop || state === "stopping" || state === "closed") { + return; + } + if (!socket || socket.readyState !== WebSocket.OPEN) { + scheduleReconnect(overlayTitle, overlayBody); + return; + } + try { + await startPeerConnection(); + setProgressOverlay(t("overlayNegotiatingTitle"), t("overlayNegotiatingBody")); + } catch (error) { + if (isCodecRequirementError(error)) { + failCodecRequirement(error); + return; + } + setProgressOverlay( + t("overlayNegotiationFailedTitle"), + error?.message || t("overlayNegotiationFailedFallback") + ); + closeSocketAndClearReference(); + scheduleReconnect(); + } + }, delay); +} + +async function sendSignal(payload) { + if (!socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify(payload)); +} + +function localOfferSDPWithIceCredentials() { + const localDescription = peer?.localDescription; + if ( + !localDescription || + localDescription.type !== "offer" || + typeof localDescription.sdp !== "string" || + localDescription.sdp.length === 0 + ) { + return null; + } + if (!/(^|\r?\n)a=ice-ufrag:/u.test(localDescription.sdp)) { + return null; + } + return localDescription.sdp; +} + +async function waitForLocalOfferSDP() { + const startedAt = performance.now(); + while (performance.now() - startedAt < localOfferIceTimeoutMs) { + const sdp = localOfferSDPWithIceCredentials(); + if (sdp) { + return sdp; + } + await new Promise((resolve) => window.setTimeout(resolve, 25)); + } + throw new Error("Local WebRTC offer is missing ICE credentials."); +} + +function normalizedVideoCodecName(codec) { + return String(codec?.mimeType || "").toLowerCase(); +} + +function isAV1Codec(codec) { + return normalizedVideoCodecName(codec) === "video/av1"; +} + +function isRetransmissionCodec(codec) { + return normalizedVideoCodecName(codec) === "video/rtx"; +} + +function codecPayloadType(codec) { + const value = Number(codec?.payloadType ?? codec?.preferredPayloadType ?? NaN); + return Number.isFinite(value) && value >= 0 ? Math.round(value) : null; +} + +function rtxAptPayloadType(codec) { + const parameterApt = Number(codec?.parameters?.apt ?? NaN); + if (Number.isFinite(parameterApt) && parameterApt >= 0) { + return Math.round(parameterApt); + } + const fmtpLine = String(codec?.sdpFmtpLine || ""); + const match = /(?:^|;)\s*apt=(\d+)\s*(?:;|$)/u.exec(fmtpLine); + return match ? Number(match[1]) : null; +} + +function rtxCodecsForPrimaryCodecs(allCodecs, primaryCodecs) { + const payloadTypes = new Set(primaryCodecs + .map(codecPayloadType) + .filter((payloadType) => payloadType !== null)); + if (payloadTypes.size === 0) { + return []; + } + return allCodecs.filter((codec) => { + if (!isRetransmissionCodec(codec)) return false; + const apt = rtxAptPayloadType(codec); + return apt !== null && payloadTypes.has(apt); + }); +} + +function codecRequirementError(message) { + const error = new Error(message); + error.codecRequirement = true; + return error; +} + +function isCodecRequirementError(error) { + return Boolean(error?.codecRequirement); +} + +function receiverCodecPreferences() { + if (!window.RTCRtpReceiver || typeof RTCRtpReceiver.getCapabilities !== "function") { + throw codecRequirementError(t("overlayCodecRequiredBody")); + } + const capabilities = RTCRtpReceiver.getCapabilities("video"); + const allCodecs = Array.isArray(capabilities?.codecs) ? capabilities.codecs : []; + const av1Codecs = allCodecs.filter(isAV1Codec); + if (av1Codecs.length === 0) { + throw codecRequirementError(t("overlayCodecRequiredBody")); + } + return av1Codecs.concat(rtxCodecsForPrimaryCodecs(allCodecs, av1Codecs)); +} + +function videoCodecNamesFromSDP(sdp) { + const lines = String(sdp || "").split(/\r?\n/u); + let payloadTypes = []; + let namesByPayloadType = new Map(); + let inVideo = false; + const codecNames = []; + + function flushVideoMedia() { + if (!inVideo) return; + for (const payloadType of payloadTypes) { + const codecName = namesByPayloadType.get(payloadType); + if (codecName) { + codecNames.push(codecName); + } + } + } + + for (const line of lines) { + if (line.startsWith("m=")) { + flushVideoMedia(); + inVideo = line.startsWith("m=video "); + payloadTypes = []; + namesByPayloadType = new Map(); + if (inVideo) { + const parts = line.trim().split(/\s+/u); + payloadTypes.push(...parts.slice(3)); + } + continue; + } + if (!inVideo || !line.startsWith("a=rtpmap:")) continue; + const match = /^a=rtpmap:(\d+)\s+([^/\s]+)/iu.exec(line); + if (match) { + namesByPayloadType.set(match[1], match[2].toLowerCase()); + } + } + flushVideoMedia(); + + return codecNames; +} + +function selectedCodecFromAnswerSDP(sdp) { + const codecNames = videoCodecNamesFromSDP(sdp); + const primaryCodecs = codecNames.filter((name) => name !== "rtx"); + const supportedPrimaryCodecs = [...new Set(primaryCodecs.filter((name) => name === "av1"))]; + const hasUnexpectedVideoCodec = primaryCodecs.some((name) => name !== "av1"); + if (supportedPrimaryCodecs.length !== 1 || hasUnexpectedVideoCodec) { + throw new Error(t("overlayCodecAnswerRequiredBody")); + } + return supportedPrimaryCodecs[0]; +} + +function browserStatsCodecName(codec) { + const mimeType = String(codec?.mimeType || "").toLowerCase(); + if (mimeType === "video/av1") return "AV1"; + return mimeType || "unknown"; +} + +function sourceSpecFromSignal(value) { + const width = Number(value?.width || 0); + const height = Number(value?.height || 0); + const framesPerSecond = Number(value?.framesPerSecond || 0); + if (width <= 0 || height <= 0 || framesPerSecond <= 0) { + return null; + } + return { + width: Math.round(width), + height: Math.round(height), + framesPerSecond: Math.round(framesPerSecond) + }; +} + +function resetBrowserStatsState() { + browserStatsState.lastBytesReceived = null; + browserStatsState.lastFramesDecoded = null; + browserStatsState.lastTimestamp = null; +} + +function stopBrowserStatsLoop() { + if (browserStatsState.timer) { + window.clearInterval(browserStatsState.timer); + browserStatsState.timer = null; + } + resetBrowserStatsState(); +} + +function videoInboundStatsFromReport(stats) { + const reports = new Map(); + stats.forEach((report) => reports.set(report.id, report)); + for (const report of reports.values()) { + const reportKind = report.kind || report.mediaType; + if ( + report.type !== "inbound-rtp" || + reportKind !== "video" || + !report.codecId + ) { + continue; + } + const codec = reports.get(report.codecId); + if (!codec?.mimeType) continue; + const mimeType = String(codec.mimeType).toLowerCase(); + if (mimeType !== "video/av1") continue; + return { report, codec }; + } + return null; +} + +function classifyLiveStats(width, height, fps, report, derived, sourceSpec) { + if (!sourceSpec) return "normal"; + const roundedFps = Math.max(0, Math.round(fps)); + const sourceFps = Number(sourceSpec.framesPerSecond || 0); + const packetsLost = Number(report.packetsLost || 0); + const framesDropped = Number(report.framesDropped || 0); + const bitrateBps = Number(derived?.bitrateBps || 0); + const hasDerivedBitrate = Boolean(derived && Number.isFinite(bitrateBps)); + const belowSourceResolution = + (width > 0 && width < sourceSpec.width) || + (height > 0 && height < sourceSpec.height); + const belowSourceFps = sourceFps > 0 && roundedFps > 0 && roundedFps < sourceFps - 5; + const cleanTransport = packetsLost === 0 && framesDropped === 0; + + if (belowSourceResolution || packetsLost > 0 || framesDropped > 0) { + return "degraded"; + } + if (belowSourceFps && cleanTransport && hasDerivedBitrate && bitrateBps < 1_000_000) { + return "lowMotion"; + } + return "normal"; +} + +function updateLiveStatusFromStats(report, codec, derived) { + const codecName = browserStatsCodecName(codec); + const width = Number(report.frameWidth || player.videoWidth || 0); + const height = Number(report.frameHeight || player.videoHeight || 0); + const fps = Number(report.framesPerSecond || derived?.framesPerSecond || 0); + const sourceSpec = expectedSourceVideoSpec; + + if (state !== "streaming" || width <= 0 || height <= 0) { + return; + } + + const roundedFps = Math.max(0, Math.round(fps)); + if (sourceSpec) { + const diagnosis = classifyLiveStats(width, height, fps, report, derived, sourceSpec); + if (diagnosis === "lowMotion") { + setVideoInfo(t( + "statusLiveLowMotionWithSource", + codecName, + width, + height, + sourceSpec.width, + sourceSpec.height, + sourceSpec.framesPerSecond + )); + return; + } + if (diagnosis === "normal") { + setVideoInfo(t( + "statusLiveWithSource", + codecName, + width, + height, + roundedFps, + sourceSpec.width, + sourceSpec.height, + sourceSpec.framesPerSecond + )); + return; + } + setVideoInfo(t( + "statusLiveBelowSource", + codecName, + width, + height, + roundedFps, + sourceSpec.width, + sourceSpec.height, + sourceSpec.framesPerSecond + )); + return; + } + setVideoInfo(t("statusLiveWithStats", codecName, width, height, roundedFps)); +} + +async function pollBrowserStats(targetPeer) { + if (!targetPeer || peer !== targetPeer || typeof targetPeer.getStats !== "function") return; + const stats = await targetPeer.getStats(); + if (peer !== targetPeer) return; + + const selected = videoInboundStatsFromReport(stats); + if (!selected) return; + + const now = Number(selected.report.timestamp || performance.now()); + const bytesReceived = Number(selected.report.bytesReceived || 0); + const framesDecoded = Number(selected.report.framesDecoded || 0); + let derived = null; + if ( + browserStatsState.lastTimestamp !== null && + now > browserStatsState.lastTimestamp + ) { + const elapsedSeconds = (now - browserStatsState.lastTimestamp) / 1000; + const byteDelta = Math.max(0, bytesReceived - browserStatsState.lastBytesReceived); + const frameDelta = Math.max(0, framesDecoded - browserStatsState.lastFramesDecoded); + derived = { + bitrateBps: elapsedSeconds > 0 ? (byteDelta * 8) / elapsedSeconds : 0, + framesPerSecond: elapsedSeconds > 0 ? frameDelta / elapsedSeconds : 0 + }; + } + browserStatsState.lastTimestamp = now; + browserStatsState.lastBytesReceived = bytesReceived; + browserStatsState.lastFramesDecoded = framesDecoded; + updateLiveStatusFromStats(selected.report, selected.codec, derived); +} + +function startBrowserStatsLoop(targetPeer) { + stopBrowserStatsLoop(); + browserStatsState.timer = window.setInterval(() => { + pollBrowserStats(targetPeer).catch((error) => { + console.warn("[VoidDisplay] Browser status update failed", error); + }); + }, browserStatsStatusIntervalMs); + pollBrowserStats(targetPeer).catch(() => {}); +} + +function failCodecRequirement(error) { + terminalStop = true; + setConnectionStatus( + t("overlayCodecRequiredTitle"), + error?.message || t("overlayCodecRequiredBody") + ); + setLoadingOverlayVisible(false); + clearReconnectTimer(); + closePeer(); + closeSocketAndClearReference(); + transition("closed"); +} + +function isCodecErrorReason(reason) { + return reason === "unsupported_video_codec_offered" || reason === "supported_video_codec_missing"; +} + +function streamStartupTimeoutError() { + const error = new Error(t("overlayFirstFrameTimeoutBody")); + error.streamStartupTimeout = true; + return error; +} + +function isStreamStartupTimeoutError(error) { + return Boolean(error?.streamStartupTimeout); +} + +function hasCurrentVideoFrame() { + return Number(player.readyState || 0) >= 2; +} + +function waitForFirstVideoFrame(timeoutMs = firstVideoFrameTimeoutMs) { + if (hasCurrentVideoFrame()) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + let resolved = false; + let timeoutID = null; + const cleanup = () => { + if (timeoutID !== null) { + window.clearTimeout(timeoutID); + timeoutID = null; + } + if (typeof player.removeEventListener === "function") { + player.removeEventListener("loadeddata", finishIfReady); + player.removeEventListener("canplay", finishIfReady); + player.removeEventListener("playing", finishIfReady); + } + }; + const settle = (callback) => { + if (resolved) return; + resolved = true; + cleanup(); + callback(); + }; + const finishIfReady = () => { + if (!hasCurrentVideoFrame()) return; + settle(resolve); + }; + timeoutID = window.setTimeout(() => { + settle(() => reject(streamStartupTimeoutError())); + }, timeoutMs); + if (typeof player.requestVideoFrameCallback === "function") { + player.requestVideoFrameCallback(() => settle(resolve)); + return; + } + player.addEventListener("loadeddata", finishIfReady); + player.addEventListener("canplay", finishIfReady); + player.addEventListener("playing", finishIfReady); + }); +} + +async function startPeerConnection() { + closePeer(); + const lifecycleID = ++peerLifecycleID; + peer = new RTCPeerConnection({ iceServers: bootstrap.iceServers ?? [] }); + const activePeer = peer; + if (typeof peer.addTransceiver !== "function") { + throw codecRequirementError(t("overlayCodecRequiredBody")); + } + const codecPreferences = receiverCodecPreferences(); + const transceiver = peer.addTransceiver("video", { direction: "recvonly" }); + if (typeof transceiver.setCodecPreferences !== "function") { + throw codecRequirementError(t("overlayCodecRequiredBody")); + } + try { + transceiver.setCodecPreferences(codecPreferences); + } catch (error) { + throw codecRequirementError(error?.message || t("overlayCodecRequiredBody")); + } + + peer.ontrack = async (event) => { + if (event.streams && event.streams[0]) { + const stream = event.streams[0]; + player.srcObject = stream; + try { + await waitForFirstVideoFrame(); + if (peer !== activePeer || lifecycleID !== peerLifecycleID || player.srcObject !== stream) { + return; + } + setConnectionStatus(t("statusLive"), t("overlayLiveBody")); + setLoadingOverlayVisible(false); + reconnectIndex = 0; + transition("streaming"); + startBrowserStatsLoop(activePeer); + } catch (error) { + if (isStreamStartupTimeoutError(error)) { + console.warn("[VoidDisplay] First video frame timed out", error); + closePeer(); + schedulePeerRetry(t("overlayFirstFrameTimeoutTitle"), error.message || t("overlayFirstFrameTimeoutBody")); + return; + } + failCodecRequirement(error); + } + } + }; + + peer.onicecandidate = (event) => { + if (!event.candidate) { + sendSignal({ type: "ice_complete" }); + return; + } + sendSignal({ + type: "ice_candidate", + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex + }); + }; + + peer.onconnectionstatechange = () => { + if (peer.connectionState === "failed" || peer.connectionState === "disconnected") { + setProgressOverlay(t("overlayConnectionLostTitle"), t("overlayConnectionLostBody")); + if (!terminalStop) { + closeSocketAndClearReference(); + scheduleReconnect(); + } + } + }; + + const offer = await peer.createOffer(); + await peer.setLocalDescription(offer); + await sendSignal({ type: "offer", sdp: await waitForLocalOfferSDP() }); + transition("negotiating"); +} + +function connect() { + if (terminalStop || state === "closed") { + return; + } + if (isSocketConnectingOrOpen(socket)) { + return; + } + if (!window.WebSocket) { + setConnectionStatus(t("overlayWebSocketRequiredTitle"), t("overlayWebSocketRequiredBody")); + setLoadingOverlayVisible(false); + transition("closed"); + return; + } + if (!window.RTCPeerConnection) { + setConnectionStatus(t("overlayWebRTCRequiredTitle"), t("overlayWebRTCRequiredBody")); + setLoadingOverlayVisible(false); + transition("closed"); + return; + } + + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const wsUrl = `${protocol}://${window.location.host}${signalPath}`; + const ws = new WebSocket(wsUrl); + socket = ws; + transition("handshaking"); + setProgressOverlay(t("overlayConnectingTitle"), t("overlayConnectingBody")); + + ws.addEventListener("open", async () => { + if (socket !== ws) return; + reconnectIndex = 0; + clearReconnectTimer(); + transition("signalingReady"); + setConnectionStatus(t("statusSignalingConnected"), t("overlayConnectingBody")); + await sendSignal({ type: "viewer_ready" }); + try { + await startPeerConnection(); + setProgressOverlay(t("overlayNegotiatingTitle"), t("overlayNegotiatingBody")); + } catch (error) { + if (isCodecRequirementError(error)) { + failCodecRequirement(error); + return; + } + setProgressOverlay( + t("overlayNegotiationFailedTitle"), + error?.message || t("overlayNegotiationFailedFallback") + ); + closeSocketAndClearReference(); + scheduleReconnect(); + } + }); + + ws.addEventListener("message", async (event) => { + if (socket !== ws) return; + if (typeof event.data !== "string") { + return; + } + + let payload; + try { + payload = JSON.parse(event.data); + } catch { + return; + } + + if (!payload || typeof payload.type !== "string") { + return; + } + + switch (payload.type) { + case "answer": + if (!peer || typeof payload.sdp !== "string") return; + try { + selectedCodecFromAnswerSDP(payload.sdp); + expectedSourceVideoSpec = sourceSpecFromSignal(payload.sourceVideoSpec); + await peer.setRemoteDescription({ + type: "answer", + sdp: payload.sdp + }); + setConnectionStatus(t("statusConnected"), t("overlayLiveBody")); + } catch (error) { + failCodecRequirement(error); + } + break; + case "ice_candidate": + if (!peer || typeof payload.candidate !== "string") return; + await peer.addIceCandidate({ + candidate: payload.candidate, + sdpMid: payload.sdpMid || null, + sdpMLineIndex: Number.isInteger(payload.sdpMLineIndex) ? payload.sdpMLineIndex : 0 + }); + break; + case "stopped": + terminalStop = true; + transition("stopping"); + setConnectionStatus(t("overlaySharingStoppedTitle"), t("overlaySharingStoppedBody")); + setLoadingOverlayVisible(false); + closePeer(); + clearReconnectTimer(); + closeSocketAndClearReference(); + transition("closed"); + break; + case "codec_pending": + setProgressOverlay(t("overlayCodecPendingTitle"), t("overlayCodecPendingBody")); + closePeer(); + schedulePeerRetry(t("overlayCodecPendingTitle"), t("overlayCodecPendingBody")); + break; + case "error": + if (isCodecErrorReason(payload.reason)) { + failCodecRequirement(new Error(t("overlayCodecAnswerRequiredBody"))); + break; + } + terminalStop = true; + setConnectionStatus(t("overlayStreamErrorTitle"), payload.reason || t("overlayStreamErrorFallback")); + setLoadingOverlayVisible(false); + clearReconnectTimer(); + closePeer(); + closeSocketAndClearReference(); + transition("closed"); + break; + default: + break; + } + }); + + ws.addEventListener("close", () => { + if (socket !== ws) return; + socket = null; + closePeer(); + if (terminalStop || state === "closed" || state === "stopping") { + setConnectionStatus(t("statusStopped")); + transition("closed"); + return; + } + scheduleReconnect(); + }); + + ws.addEventListener("error", () => { + if (socket !== ws) return; + setConnectionStatus(t("statusConnectionError")); + }); +} + +window.addEventListener("beforeunload", () => { + terminalStop = true; + clearReconnectTimer(); + closePeer(); + closeSocketAndClearReference(); +}); + +connect(); diff --git a/Sources/VoidDisplaySharing/Services/WebServiceController.swift b/Sources/VoidDisplaySharing/Services/WebServiceController.swift index e6bea9e..f43c213 100644 --- a/Sources/VoidDisplaySharing/Services/WebServiceController.swift +++ b/Sources/VoidDisplaySharing/Services/WebServiceController.swift @@ -4,7 +4,6 @@ import VoidDisplayObservability import Foundation import Network import OSLog -import Darwin package enum WebServiceServerStopReason: Equatable { case requested case startupCancelled @@ -254,16 +253,6 @@ package final class WebServiceController: WebServiceControllerProtocol { return .failed(failure) } - if let preflightFailure = Self.preflightBindingFailure(for: requestedPort) { - AppLog.web.error( - "Web service preflight failed (requestedPort: \(requestedPort, privacy: .public), reason: \(String(describing: preflightFailure), privacy: .public))." - ) - if isCurrentOperation(operationNonce) { - setLifecycleState(.failed(preflightFailure)) - } - return .failed(preflightFailure) - } - if let relayProcessController { do { _ = try await relayProcessController.client() @@ -475,64 +464,4 @@ package final class WebServiceController: WebServiceControllerProtocol { message: String(localized: "Failed to start web service on port \(requestedPort).") ) } - - package static func preflightBindingFailure(for requestedPort: UInt16) -> WebServiceStartFailure? { - let socketDescriptor = socket(AF_INET, SOCK_STREAM, 0) - guard socketDescriptor >= 0 else { - return .listenerFailed( - port: requestedPort, - message: String(localized: "Failed to prepare web service socket for port \(requestedPort).") - ) - } - defer { close(socketDescriptor) } - - var reuseAddress = Int32(1) - let optionResult = setsockopt( - socketDescriptor, - SOL_SOCKET, - SO_REUSEADDR, - &reuseAddress, - socklen_t(MemoryLayout.size) - ) - guard optionResult == 0 else { - return .listenerFailed( - port: requestedPort, - message: String(localized: "Failed to prepare web service socket for port \(requestedPort).") - ) - } - - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout.size) - address.sin_family = sa_family_t(AF_INET) - address.sin_port = requestedPort.bigEndian - address.sin_addr = in_addr(s_addr: INADDR_ANY.bigEndian) - - let bindResult = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in - bind(socketDescriptor, sockaddrPointer, socklen_t(MemoryLayout.size)) - } - } - - guard bindResult == 0 else { - if let code = POSIXErrorCode(rawValue: errno) { - switch code { - case .EADDRINUSE: - return .portInUse(port: requestedPort) - case .EACCES: - return .permissionDenied(port: requestedPort) - default: - return .listenerFailed( - port: requestedPort, - message: String(localized: "Failed to start web service on port \(requestedPort).") - ) - } - } - return .listenerFailed( - port: requestedPort, - message: String(localized: "Failed to start web service on port \(requestedPort).") - ) - } - - return nil - } } diff --git a/Sources/VoidDisplaySharing/Web/RelaySessionHub.swift b/Sources/VoidDisplaySharing/Web/RelaySessionHub.swift index f6c893c..b3a6587 100644 --- a/Sources/VoidDisplaySharing/Web/RelaySessionHub.swift +++ b/Sources/VoidDisplaySharing/Web/RelaySessionHub.swift @@ -43,6 +43,12 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { case dropped } + private nonisolated enum PublisherStartupWaitResult { + case ready + case timedOut + case failed + } + private nonisolated struct ClientState { nonisolated(unsafe) let connection: any SignalSocketConnection let clientID: String @@ -71,6 +77,7 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { private let state: Mutex private let relayClientProvider: RelayClientProvider private let publisherFactory: PublisherFactory + private let publisherStartupWaitTimeout: Duration nonisolated private static let maxPendingSignalsPerClient = 256 #if canImport(WebRTC) @@ -86,7 +93,8 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { package init( onDemandChanged: @escaping @Sendable (Bool) -> Void = { _ in }, relayClientProvider: @escaping RelayClientProvider, - publisherFactory: PublisherFactory? = nil + publisherFactory: PublisherFactory? = nil, + publisherStartupWaitTimeout: Duration = .seconds(2) ) { self.state = Mutex(State(onDemandChanged: onDemandChanged)) #if canImport(WebRTC) @@ -103,6 +111,7 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { self.publisherFactory = publisherFactory ?? { _, _, _ in nil } #endif self.relayClientProvider = relayClientProvider + self.publisherStartupWaitTimeout = publisherStartupWaitTimeout } package nonisolated var activeClientCount: Int { @@ -420,6 +429,36 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { key: ObjectIdentifier ) async { do { + guard isCurrentViewer(key: key, roomID: roomID, clientID: clientID, sessionEpoch: sessionEpoch) else { + return + } + ensurePublisher(roomID: roomID) + switch await waitForPublisherStartup(roomID: roomID) { + case .ready: + break + case .timedOut: + guard isCurrentViewer(key: key, roomID: roomID, clientID: clientID, sessionEpoch: sessionEpoch) else { + return + } + enqueue( + message: SignalingOutboundMessage(type: .codecPending, reason: "publisher_codec_pending"), + to: key, + disconnectAfterSend: false, + replacePending: true + ) + return + case .failed: + guard isCurrentViewer(key: key, roomID: roomID, clientID: clientID, sessionEpoch: sessionEpoch) else { + return + } + enqueue( + message: SignalingOutboundMessage(type: .error, reason: "relay_publisher_unavailable"), + to: key, + disconnectAfterSend: true, + replacePending: true + ) + return + } guard isCurrentViewer(key: key, roomID: roomID, clientID: clientID, sessionEpoch: sessionEpoch) else { return } @@ -484,6 +523,30 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { } } + private nonisolated func waitForPublisherStartup(roomID: String) async -> PublisherStartupWaitResult { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: publisherStartupWaitTimeout) + while true { + let waitResult = state.withLock { state -> PublisherStartupWaitResult? in + guard state.roomID == roomID else { return .failed } + if state.publisher != nil { + return .ready + } + guard state.publisherTask != nil else { + return .failed + } + return nil + } + if let waitResult { + return waitResult + } + if clock.now >= deadline { + return .timedOut + } + try? await Task.sleep(for: .milliseconds(25)) + } + } + private nonisolated func isCurrentViewer( key: ObjectIdentifier, roomID: String, diff --git a/Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift b/Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift index ac43813..0df309b 100644 --- a/Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift +++ b/Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift @@ -31,7 +31,6 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { private let profileState: Mutex private let activeCodecsState = Mutex>([]) private let publisherIDState = Mutex(nil) - private let iceGatheringWaiters = Mutex<[CheckedContinuation]>([]) private let pendingPublisherCandidates = Mutex<[RTCIceCandidate]>([]) nonisolated(unsafe) private var videoTransceivers: [VideoTransceiverBinding] = [] nonisolated(unsafe) private var diagnosticsTask: Task? @@ -141,7 +140,6 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { pendingPublisherCandidates.withLock { $0.removeAll() } diagnosticsTask?.cancel() diagnosticsTask = nil - resumeIceGatheringWaiters() peerConnection.close() guard let publisherID else { return } Task { [relayClient, roomID, publisherID] in @@ -180,7 +178,6 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) let offer = try await createOffer(constraints: constraints) try await setLocalDescription(offer) - await waitForInitialIceGathering() try throwIfClosed() let finalOffer = peerConnection.localDescription ?? offer let localCodecSummary = WebRTCCodecPreference.sdpVideoCodecSummary(from: finalOffer.sdp) @@ -259,33 +256,6 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { } } - private nonisolated func waitForIceGatheringComplete() async { - guard peerConnection.iceGatheringState != .complete else { return } - guard !isClosed else { return } - await withCheckedContinuation { continuation in - let shouldResumeImmediately = iceGatheringWaiters.withLock { waiters -> Bool in - guard peerConnection.iceGatheringState != .complete, !isClosed else { return true } - waiters.append(continuation) - return false - } - if shouldResumeImmediately { - continuation.resume(returning: ()) - } - } - } - - private nonisolated func waitForInitialIceGathering() async { - let deadline = Date().addingTimeInterval(1) - while peerConnection.iceGatheringState != .complete, !isClosed, Date() < deadline { - try? await Task.sleep(nanoseconds: 25_000_000) - } - if peerConnection.iceGatheringState != .complete { - AppLog.web.info( - "WebRTC publisher continuing before ICE gathering complete state=\(String(describing: self.peerConnection.iceGatheringState), privacy: .public)." - ) - } - } - private nonisolated var isClosed: Bool { stateLock.withLock { state in if case .closed = state { return true } @@ -299,17 +269,6 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { } } - private nonisolated func resumeIceGatheringWaiters() { - let waiters = iceGatheringWaiters.withLock { waiters -> [CheckedContinuation] in - let current = waiters - waiters.removeAll() - return current - } - for waiter in waiters { - waiter.resume(returning: ()) - } - } - private nonisolated func configureDesktopVideoSender( _ sender: RTCRtpSender?, codec: WebRTCVideoCodec, @@ -475,11 +434,7 @@ extension WebRTCPublisherSession: RTCPeerConnectionDelegate { break } } - package nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) { - if newState == .complete { - resumeIceGatheringWaiters() - } - } + package nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {} package nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) { sendPublisherCandidate(candidate) diff --git a/Sources/VoidDisplaySharing/Web/WebServer.swift b/Sources/VoidDisplaySharing/Web/WebServer.swift index 28adbff..6a25986 100644 --- a/Sources/VoidDisplaySharing/Web/WebServer.swift +++ b/Sources/VoidDisplaySharing/Web/WebServer.swift @@ -72,13 +72,29 @@ package final class WebServer { """ } - private static func loadDisplayPageTemplate() throws -> String { - if let path = Bundle.module.path(forResource: "displayPage", ofType: "html") { + private struct DisplayPageResources { + let template: String + let styles: String + let messagesScript: String + let runtimeScript: String + } + + private static func loadResource(named name: String, ofType type: String) throws -> String { + if let path = Bundle.module.path(forResource: name, ofType: type) { return try String(contentsOfFile: path, encoding: .utf8) } throw InitError.missingDisplayPageResource } + private static func loadDisplayPageResources() throws -> DisplayPageResources { + try DisplayPageResources( + template: loadResource(named: "displayPage", ofType: "html"), + styles: loadResource(named: "displayPage", ofType: "css"), + messagesScript: loadResource(named: "displayPageMessages", ofType: "js"), + runtimeScript: loadResource(named: "displayPageRuntime", ofType: "js") + ) + } + private struct ActiveConnection { let target: ShareTarget let clientID: String @@ -87,7 +103,7 @@ package final class WebServer { } private var listener: NWListener? - private let displayPageTemplate: String + private let displayPageResources: DisplayPageResources private let requestHandler = WebRequestHandler() private var activeConnections: [ObjectIdentifier: ActiveConnection] = [:] private var signalDecodersByConnectionKey: [ObjectIdentifier: WebSocketFrameDecoder] = [:] @@ -118,11 +134,12 @@ package final class WebServer { self.sharingEventSink = sharingEventSink self.onListenerStopped = onListenerStopped - displayPageTemplate = try Self.loadDisplayPageTemplate() + displayPageResources = try Self.loadDisplayPageResources() let tcpOptions = NWProtocolTCP.Options() tcpOptions.noDelay = true let params = NWParameters(tls: nil, tcp: tcpOptions) + params.allowLocalEndpointReuse = true listener = try NWListener(using: params, on: port) listener?.stateUpdateHandler = { [weak self] state in Task { @MainActor [weak self] in @@ -145,6 +162,13 @@ package final class WebServer { } deinit { + startupTimeoutTask?.cancel() + startupWaiter?.resume(returning: .failed(error: LifecycleError.listenerCancelled)) + for activeConnection in activeConnections.values { + activeConnection.sessionHub.removeClient(activeConnection.connection) + activeConnection.connection.cancel() + } + activeConnections.removeAll() listener?.cancel() listener = nil } @@ -310,8 +334,17 @@ package final class WebServer { private func displayPage(for target: ShareTarget) -> String { _ = target let title = "Screen Share" - return displayPageTemplate + return displayPageResources.template .replacingOccurrences(of: "__PAGE_TITLE__", with: title) + .replacingOccurrences(of: "__DISPLAY_PAGE_STYLES__", with: displayPageResources.styles) + .replacingOccurrences( + of: "__DISPLAY_PAGE_MESSAGES_SCRIPT__", + with: displayPageResources.messagesScript + ) + .replacingOccurrences( + of: "__DISPLAY_PAGE_RUNTIME_SCRIPT__", + with: displayPageResources.runtimeScript + ) .replacingOccurrences(of: "__SIGNAL_PATH__", with: target.signalPath) .replacingOccurrences(of: "__BOOTSTRAP_JSON__", with: makeDisplayPageBootstrapJSON()) } diff --git a/Tests/VoidDisplaySharingTests/Integration/SharingEndToEndIntegrationTests.swift b/Tests/VoidDisplaySharingTests/Integration/SharingEndToEndIntegrationTests.swift index 1e508c3..b74fe42 100644 --- a/Tests/VoidDisplaySharingTests/Integration/SharingEndToEndIntegrationTests.swift +++ b/Tests/VoidDisplaySharingTests/Integration/SharingEndToEndIntegrationTests.swift @@ -195,6 +195,36 @@ struct SharingEndToEndIntegrationTests { #expect(signalText.contains("503 Service Unavailable")) } + @MainActor + @Test + func webServiceRestartsSamePortImmediatelyAfterStop() async throws { + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()) + ) + let webServiceController = WebServiceController() + let service = SharingService( + webServiceController: webServiceController, + sharingCoordinator: coordinator + ) + + let firstStart = await startWebServiceWithDynamicPorts(service: service) + let firstBinding = try requireBinding(firstStart) + let port = firstBinding.boundPort + + let rootRequest = Data("GET / HTTP/1.1\r\nHost: 127.0.0.1:\(port)\r\n\r\n".utf8) + _ = try await Task.detached { + try await sendRequestAndReadUntilClose(port: port, request: rootRequest) + }.value + + service.stopWebService() + + let secondStart = await service.startWebService(requestedPort: port) + defer { + service.stopWebService() + } + #expect(secondStart == .started(WebServiceBinding(requestedPort: port, boundPort: port))) + } + private func waitForConnectionFailure( port: UInt16, path: String, diff --git a/Tests/VoidDisplaySharingTests/Integration/WebServerSocketIntegrationTests.swift b/Tests/VoidDisplaySharingTests/Integration/WebServerSocketIntegrationTests.swift index 89ce57b..d143c96 100644 --- a/Tests/VoidDisplaySharingTests/Integration/WebServerSocketIntegrationTests.swift +++ b/Tests/VoidDisplaySharingTests/Integration/WebServerSocketIntegrationTests.swift @@ -4,6 +4,7 @@ import Foundation import Darwin import JavaScriptCore +import Network import Testing @MainActor @@ -31,6 +32,83 @@ struct WebServerSocketIntegrationTests { #expect(responseText.contains("VoidDisplay Share")) } + @Test func stoppedListenerAllowsImmediateSamePortRestartAfterHTTPClientTraffic() async throws { + let setup = try await startServerOnRandomPort( + targetStateProvider: { _ in .unknown }, + sessionHubProvider: { _ in nil } + ) + let firstServer = setup.server + let portValue = setup.port + + let request = Data("GET / HTTP/1.1\r\nHost: 127.0.0.1:\(portValue)\r\n\r\n".utf8) + _ = try await Task.detached { + try await sendRequestAndReadUntilServerClose(port: portValue, request: request) + }.value + + firstServer.stopListener() + + let endpointPort = try #require(NWEndpoint.Port(rawValue: portValue)) + let reboundServer = try WebServer( + using: endpointPort, + targetStateProvider: { _ in .unknown }, + concreteTargetResolver: { _ in nil }, + sessionHubProvider: { _ in nil }, + sharingEventSink: { _ in } + ) + defer { reboundServer.stopListener() } + + let reboundResult = await reboundServer.startListener(timeout: 1.0) + guard case .ready(let reboundPort) = reboundResult else { + Issue.record("Expected immediate same-port restart, got \(String(describing: reboundResult)).") + return + } + #expect(reboundPort == portValue) + } + + @Test func stoppedListenerAllowsImmediateSamePortRestartAfterActiveWebSocketTraffic() async throws { + let sessionHub = TestSignalSessionHub() + let setup = try await startServerOnRandomPort( + targetStateProvider: { target in + target == .main ? .active : .unknown + }, + concreteTargetResolver: { target in + target == .main ? .id(Self.mainAliasShareID) : nil + }, + sessionHubProvider: { target in + target == .id(Self.mainAliasShareID) ? sessionHub : nil + } + ) + let firstServer = setup.server + let portValue = setup.port + + let socket = try await openWebSocket(path: "/signal", port: portValue) + defer { close(socket) } + let connected = await waitUntilAsync(timeout: .seconds(2)) { + firstServer.activeStreamClientCount == 1 && sessionHub.activeClientCount == 1 + } + #expect(connected) + + firstServer.stopListener() + #expect(try await waitForSocketClose(socket)) + + let endpointPort = try #require(NWEndpoint.Port(rawValue: portValue)) + let reboundServer = try WebServer( + using: endpointPort, + targetStateProvider: { _ in .unknown }, + concreteTargetResolver: { _ in nil }, + sessionHubProvider: { _ in nil }, + sharingEventSink: { _ in } + ) + defer { reboundServer.stopListener() } + + let reboundResult = await reboundServer.startListener(timeout: 1.0) + guard case .ready(let reboundPort) = reboundResult else { + Issue.record("Expected immediate same-port restart after active WebSocket traffic, got \(String(describing: reboundResult)).") + return + } + #expect(reboundPort == portValue) + } + @Test func liveRouteUpgradesToWebSocketWhenTargetActive() async throws { let sessionHub = TestSignalSessionHub() let setup = try await startServerOnRandomPort( @@ -123,25 +201,30 @@ struct WebServerSocketIntegrationTests { #expect(responseText.contains(#"async function waitForLocalOfferSDP() {"#)) #expect(responseText.contains(#"RTCRtpReceiver.getCapabilities("video")"#)) #expect(responseText.contains(#"function receiverCodecPreferences() {"#)) - #expect(responseText.contains(#"function receiverVideoCodecCapabilitySummary() {"#)) - #expect(responseText.contains(#""unsupportedVideoCodecCount=" + String(allCodecs.length - probedCodecCount)"#)) - #expect(responseText.contains(#"WebRTC receiver video capabilities "#)) + #expect(responseText.contains(#"WebRTC receiver video capabilities "#) == false) + #expect(responseText.contains(#"WebRTC browser stats"#) == false) #expect(!responseText.contains(#"function initialForceH264Only() {"#)) #expect(!responseText.contains(#"forceH264Only"#)) #expect(responseText.contains(#"return normalizedVideoCodecName(codec) === "video/av1";"#)) #expect(responseText.contains(#"function isRetransmissionCodec(codec) {"#)) #expect(!responseText.contains(#"function receiverSupportsCodec(mimeType) {"#)) - #expect(responseText.contains(#"function matchingRtxCodecs(allCodecs, primaryCodecs) {"#)) - #expect(responseText.contains(#"return supportedCodecs.concat(matchingRtxCodecs(allCodecs, supportedCodecs));"#)) + #expect(responseText.contains(#"function rtxCodecsForPrimaryCodecs(allCodecs, primaryCodecs) {"#)) + #expect(responseText.contains(#"return av1Codecs.concat(rtxCodecsForPrimaryCodecs(allCodecs, av1Codecs));"#)) #expect(responseText.contains(#"transceiver.setCodecPreferences(codecPreferences);"#)) #expect(responseText.contains(#"const supportedPrimaryCodecs = [...new Set("#)) - #expect(responseText.contains(#"negotiatedVideoCodec = selectedCodecFromAnswerSDP(payload.sdp);"#)) - #expect(responseText.contains(#"await verifySelectedCodec(negotiatedVideoCodec || "av1");"#)) + #expect(responseText.contains(#"selectedCodecFromAnswerSDP(payload.sdp);"#)) + #expect(responseText.contains(#"function setVideoInfo(text) {"#)) + #expect(responseText.contains(#"function setConnectionStatus(title, detail = "") {"#)) + #expect(responseText.contains(#"function startBrowserStatsLoop(targetPeer) {"#)) + #expect(responseText.contains(#"expectedSourceVideoSpec = sourceSpecFromSignal(payload.sourceVideoSpec);"#)) + #expect(responseText.contains(#"statusLiveWithStats"#)) + #expect(responseText.contains(#"statusLiveBelowSource"#)) + #expect(responseText.contains(#"verifySelectedCodec"#) == false) + #expect(responseText.contains(#"offerToReceiveVideo"#) == false) #expect(responseText.contains(#"function isCodecErrorReason(reason) {"#)) #expect(!responseText.contains(#"function shouldRetryWithH264Fallback() {"#)) #expect(!responseText.contains(#"function retryWithH264Fallback() {"#)) #expect(responseText.contains(#"reason === "unsupported_video_codec_offered" || reason === "supported_video_codec_missing""#)) - #expect(responseText.contains(#"if (codecMimeType === "video/rtx") {"#)) #expect(responseText.contains(#"peer.addTransceiver("video", { direction: "recvonly" });"#)) #expect(responseText.contains(#"sdp: await waitForLocalOfferSDP()"#)) #expect(!responseText.contains(#"sdp: offer.sdp"#)) @@ -149,25 +232,61 @@ struct WebServerSocketIntegrationTests { #expect(responseText.contains(#"function scheduleReconnect(overlayTitle = t("overlayReconnectTitle"), overlayBody = t("overlayReconnectBody")) {"#)) #expect(responseText.contains(#"function schedulePeerRetry(overlayTitle, overlayBody) {"#)) #expect(responseText.contains(#"peer = new RTCPeerConnection({ iceServers: bootstrap.iceServers ?? [] });"#)) - #expect(responseText.contains(#"setOverlay(overlayTitle, overlayBody, true);"#)) - #expect(responseText.contains(#"setOverlay(t("overlaySharingStoppedTitle"), t("overlaySharingStoppedBody"), true);"#)) + #expect(responseText.contains(#"function setStartupOverlay() {"#) == false) + #expect(responseText.contains(#"function setProgressOverlay(title, body) {"#)) + #expect(responseText.contains(#"function waitForFirstVideoFrame(timeoutMs = firstVideoFrameTimeoutMs) {"#)) + #expect(responseText.contains(#"await waitForFirstVideoFrame();"#)) + #expect(responseText.contains(#"const firstVideoFrameTimeoutMs = 10000;"#)) + #expect(responseText.contains(#"function streamStartupTimeoutError() {"#)) + #expect(responseText.contains(#"schedulePeerRetry(t("overlayFirstFrameTimeoutTitle"), error.message || t("overlayFirstFrameTimeoutBody"));"#)) + #expect(responseText.contains(#"setProgressOverlay(t("overlayConnectionLostTitle"), t("overlayConnectionLostBody"));"#)) + #expect(responseText.contains(#"setConnectionStatus(t("overlaySharingStoppedTitle"), t("overlaySharingStoppedBody"));"#)) #expect(responseText.contains(#"case "stopped":"#)) #expect(responseText.contains(#"case "codec_pending":"#)) #expect(responseText.contains(#"schedulePeerRetry(t("overlayCodecPendingTitle"), t("overlayCodecPendingBody"));"#)) #expect(responseText.contains(#"case "error":"#)) #expect(responseText.contains(#"connect();"#)) #expect(responseText.contains(#"heroEyebrow: "VOIDDISPLAY 实时画面""#)) + #expect(responseText.contains(#"overlayStartupTitle"#) == false) + #expect(responseText.contains(#"statusReconnecting"#) == false) + #expect(responseText.contains(#"ms 后重连"#) == false) #expect(responseText.contains(#"overlayCodecRequiredTitle: "AV1 required""#)) #expect(responseText.contains(#"overlayCodecRequiredTitle: "需要 AV1""#)) + #expect(responseText.contains(#"overlayFirstFrameTimeoutTitle: "Waiting for video""#)) + #expect(responseText.contains(#"overlayFirstFrameTimeoutTitle: "正在等待画面""#)) #expect(responseText.contains(#"overlayCodecPendingTitle: "Preparing video codec""#)) #expect(responseText.contains(#"overlayCodecPendingTitle: "正在准备视频编码""#)) #expect(!responseText.contains(#"overlayCodecFallbackTitle"#)) #expect(responseText.contains(#"fullscreenEnter: "全屏""#)) #expect(responseText.contains(#"pageTitle: "Screen Share""#)) #expect(responseText.contains("hero-eyebrow")) + #expect(responseText.contains("video-info")) + #expect(responseText.contains("connection-status")) + #expect(responseText.contains("loading-spinner")) + #expect(responseText.contains("justify-items: start;")) + #expect(responseText.contains("width: clamp(14rem, 24vw, 24rem);")) + #expect(responseText.contains("margin-left: auto;")) + #expect(responseText.contains("background: rgba(8, 11, 17, 0.62);") == false) + #expect(responseText.contains("border-radius: 10px;") == false) + #expect(responseText.contains("
\n

VoidDisplay Live

\n
\n
\n
\n
")) + #expect(responseText.contains("grid-template-columns: minmax(12rem, 1fr) minmax(0, auto);")) + #expect(responseText.contains(".hero-metadata")) + #expect(responseText.contains("justify-self: end;")) + #expect(responseText.contains("text-align: right;")) + #expect(responseText.contains("width: clamp(28rem, 42vw, 42rem);")) + #expect(responseText.contains("font-variant-numeric: tabular-nums;")) + #expect(responseText.contains(#"font-feature-settings: "tnum" 1;"#)) + #expect(responseText.contains(".video-info:empty")) + #expect(responseText.contains("
\n \n
")) + #expect(responseText.contains("