import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; import { checkFeatureAccess } from "./feature-gate.js"; import { WhepClient } from "./whep.js"; import { P2PClient } from "./webrtc-p2p.js"; import { AdaptiveDesktopQuality, normalizeAdaptiveProfiles } from "./rd-adaptive-quality.js"; import { createKeyboardCapture } from "./keyboard-capture.js"; import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js"; (async function () { const clientId = new URLSearchParams(location.search).get("clientId"); if (!clientId) { alert("Missing clientId"); return; } const allowed = await checkFeatureAccess("remote_desktop", clientId); if (!allowed) return; const clientLabel = document.getElementById("clientLabel"); clientLabel.textContent = clientId; let ws = null; let reconnectTimer = null; let reconnectDelay = 1000; const maxReconnectDelay = 15000; function buildWsUrl() { return (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/api/clients/" + clientId + "/rd/ws"; } function connectWs() { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } ws = new WebSocket(buildWsUrl()); ws.binaryType = "arraybuffer"; ws.addEventListener("open", onWsOpen); ws.addEventListener("message", onWsMessage); ws.addEventListener("close", onWsClose); ws.addEventListener("error", onWsError); } function scheduleReconnect() { if (reconnectTimer) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; setStreamState("connecting", "Reconnecting"); connectWs(); reconnectDelay = Math.min(reconnectDelay * 1.5, maxReconnectDelay); }, reconnectDelay); } const displaySelect = document.getElementById("displaySelect"); const refreshBtn = document.getElementById("refreshDisplays"); const startBtn = document.getElementById("startBtn"); const stopBtn = document.getElementById("stopBtn"); const recordBtn = document.getElementById("recordBtn"); const rdRecordingSettingsBtn = document.getElementById("rdRecordingSettingsBtn"); const recordMode = document.getElementById("recordMode"); const recordFps = document.getElementById("recordFps"); const fullscreenBtn = document.getElementById("fullscreenBtn"); const fullscreenExitBtn = document.getElementById("fullscreenExitBtn"); const mouseCtrl = document.getElementById("mouseCtrl"); const kbdCtrl = document.getElementById("kbdCtrl"); const cursorCtrl = document.getElementById("cursorCtrl"); const hideLocalCursorCtrl = document.getElementById("hideLocalCursorCtrl"); const duplicationCtrl = document.getElementById("duplicationCtrl"); const streamProfileSelect = document.getElementById("streamProfileSelect"); const streamProfileDetail = document.getElementById("streamProfileDetail"); const bitrateSelect = document.getElementById("bitrateSelect"); const requestKeyframeBtn = document.getElementById("requestKeyframeBtn"); const smoothingSlider = document.getElementById("smoothingSlider"); const smoothingValue = document.getElementById("smoothingValue"); const qualitySlider = document.getElementById("qualitySlider"); const qualityValue = document.getElementById("qualityValue"); const codecH264 = document.getElementById("codecH264"); const softwareH264Ctrl = document.getElementById("softwareH264Ctrl"); const codecMode = document.getElementById("codecMode"); const canvas = document.getElementById("frameCanvas"); const canvasContainer = document.getElementById("canvasContainer"); const renderSurface = document.getElementById("renderSurface"); const remoteCursor = document.getElementById("remoteCursor"); const remoteCursorImage = document.getElementById("remoteCursorImage"); const ctx = canvas.getContext("2d"); const agentFps = document.getElementById("agentFps"); const viewerFps = document.getElementById("viewerFps"); const inputLatency = document.getElementById("inputLatency"); const networkStats = document.getElementById("networkStats"); const diagnosticsBtn = document.getElementById("diagnosticsBtn"); const diagnosticsHud = document.getElementById("diagnosticsHud"); const diagnosticsClose = document.getElementById("diagnosticsClose"); const fullscreenHud = document.getElementById("fullscreenHud"); const fsHudStateDot = document.getElementById("fsHudStateDot"); const fsHudStatus = document.getElementById("fsHudStatus"); const fsHudResolution = document.getElementById("fsHudResolution"); const fsHudCodec = document.getElementById("fsHudCodec"); const fsHudFps = document.getElementById("fsHudFps"); const fsHudLatency = document.getElementById("fsHudLatency"); const fsHudNetwork = document.getElementById("fsHudNetwork"); const fsHudStatsBtn = document.getElementById("fsHudStatsBtn"); const fsHudTuckBtn = document.getElementById("fsHudTuckBtn"); const fsHudExitBtn = document.getElementById("fsHudExitBtn"); const rdStateOverlay = document.getElementById("rdStateOverlay"); const rdStateIcon = document.getElementById("rdStateIcon"); const rdStateTitle = document.getElementById("rdStateTitle"); const rdStateDetail = document.getElementById("rdStateDetail"); const rdStateKeyframeBtn = document.getElementById("rdStateKeyframeBtn"); const rdStateRestartBtn = document.getElementById("rdStateRestartBtn"); const rdStateTransportBtn = document.getElementById("rdStateTransportBtn"); const diagnosticEls = Object.fromEntries([ "Summary", "Transport", "Codec", "Resolution", "Capture", "Encode", "Send", "AgentTotal", "Bitrate", "Rtt", "LossJitter", "JitterBuffer", "Decode", "Render", "Queue", "Dropped", "Fps", "Input", ].map((name) => [name, document.getElementById(`diag${name}`)])); const statusEl = document.getElementById("streamStatus"); const clipboardSyncCtrl = document.getElementById("clipboardSyncCtrl"); const privacyCtrl = document.getElementById("privacyCtrl"); const audioCtrl = document.getElementById("audioCtrl"); const webrtcMode = document.getElementById("webrtcMode"); const webrtcVideo = document.getElementById("webrtcVideo"); let whepClient = null; let p2pClient = null; let webrtcActive = false; let lastRemoteCursor = null; let remoteCursorShape = null; let remoteCursorImageUrl = ""; const CANVAS_TRANSPORT_PREF_VERSION = 1; function getWebrtcMode() { return webrtcMode ? String(webrtcMode.value || "off") : "off"; } let activeClientId = clientId; let serverRecording = null; let recordingTimer = null; let pendingRecordingDownloadId = ""; let renderCount = 0; let renderWindowStart = performance.now(); let lastFrameAt = 0; let desiredStreaming = false; let streamState = "connecting"; let streamStatusLabel = "Connecting"; let fullscreenHudTimer = null; let fullscreenHudTucked = false; let frameWatchTimer = null; let offlineTimer = null; let frameWidth = 0; let frameHeight = 0; let latencyAvg = null; let smoothingPct = 20; let smoothPoint = null; let pendingMove = null; let moveFrame = 0; let frameDecodeBusy = false; let pendingFrame = null; let videoDecoder = null; let videoDecoderConfigKey = ""; let pendingDecodedVideoFrame = null; let decodedVideoPresentationFrame = 0; const disabledDecoderCodecs = new Set(); let h264StreamCodec = ""; let canvasDecodePressure = false; let h264PendingTimings = []; let h264TimestampUs = 0; let prefersH264 = typeof VideoDecoder === "function"; let negotiatedCodec = prefersH264 ? "h264" : "jpeg"; let browserDecoderCodecs = ["jpeg", "raw"]; let h264LowFpsStreak = 0; let h264FirstFrameAt = 0; let h264FramesSeen = 0; let h264KeyframeErrorStreak = 0; let h264RecoveryAttempts = 0; let h264LastDecodeWarnAt = 0; let h264WaitingForKeyframe = true; const H264_LOW_FPS_THRESHOLD = 6; const H264_FALLBACK_WARMUP_MS = 10000; const H264_MIN_FRAMES_BEFORE_FALLBACK = 120; const H264_LOW_FPS_STREAK_LIMIT = 120; const H264_KEYFRAME_ERROR_RESTART_THRESHOLD = 24; const H264_MAX_RECOVERY_ATTEMPTS = 1; const H264_DECODE_WARN_THROTTLE_MS = 2000; const inputBackpressureBytes = 256 * 1024; const VIEWER_FRAME_GAP_KEYFRAME_THROTTLE_MS = 1000; let lastViewerKeyframeRequestAt = 0; const diagnostics = { agent: null, network: null, decodeMs: null, renderMs: null, decodeQueue: 0, coalescedFrames: 0, currentAgentFps: null, currentViewerFps: null, wsBitrateMbps: null, wsBytes: 0, wsWindowStartedAt: performance.now(), codec: "", width: 0, height: 0, }; let encoderProfiles = []; let adaptiveProfileRunning = false; const adaptiveQuality = new AdaptiveDesktopQuality((target) => { sendCmd("desktop_set_profile", { maxHeight: target.maxHeight, fps: target.fps, source: "webrtc_auto", reason: target.reason, }); sendCmd("desktop_set_bitrate", { bitrateMbps: target.bitrateMbps, adaptive: true, source: "webrtc_auto", reason: target.reason, }); if (streamProfileDetail) { streamProfileDetail.textContent = `Auto: ${target.width}×${target.height} @ ${target.fps} FPS, ${target.bitrateMbps} Mbps. ${target.reason}.`; } }); let clipboardSyncTimer = null; let lastClipboardText = ""; let clipboardSyncActive = false; let elevationPending = false; let clientOs = ""; let clientIsAdmin = false; let firewallWarningAcked = false; let firstFrameLogged = false; function rdDebug(label, data = {}) { try { console.debug(`rd: ${label} ${JSON.stringify(data)}`); } catch { console.debug(`rd: ${label}`, data); } } function resetH264RuntimeState() { h264TimestampUs = 0; h264StreamCodec = ""; h264PendingTimings = []; diagnostics.decodeQueue = 0; h264LowFpsStreak = 0; h264FirstFrameAt = 0; h264FramesSeen = 0; h264KeyframeErrorStreak = 0; h264WaitingForKeyframe = true; } /* ── Remote Desktop Audio (system audio from client) ── */ const AUDIO_SAMPLE_RATE = 16000; const AUDIO_PLAYBACK_FRAME = 512; const AUDIO_MAX_BUFFER_MS = 120; let audioWs = null; let audioPlayCtx = null; let audioProcessorNode = null; let audioChunks = []; let audioChunkOffset = 0; function audioResampleInt16ToFloat32(srcInt16, srcRate, dstRate) { if (!srcInt16 || srcInt16.length === 0) return new Float32Array(0); if (srcRate === dstRate) { const out = new Float32Array(srcInt16.length); for (let i = 0; i < srcInt16.length; i++) out[i] = srcInt16[i] / 0x8000; return out; } const outLength = Math.max(1, Math.round((srcInt16.length * dstRate) / srcRate)); const out = new Float32Array(outLength); const step = srcRate / dstRate; for (let i = 0; i < outLength; i++) { const srcPos = i * step; const i0 = Math.floor(srcPos); const i1 = Math.min(i0 + 1, srcInt16.length - 1); const frac = srcPos - i0; out[i] = (srcInt16[i0] * (1 - frac) + srcInt16[i1] * frac) / 0x8000; } return out; } function initAudioPlayback() { if (!audioPlayCtx) { audioPlayCtx = new AudioContext({ sampleRate: AUDIO_SAMPLE_RATE, latencyHint: "interactive" }); } if (!audioProcessorNode) { audioProcessorNode = audioPlayCtx.createScriptProcessor(AUDIO_PLAYBACK_FRAME, 1, 1); audioProcessorNode.onaudioprocess = function (event) { const out = event.outputBuffer.getChannelData(0); out.fill(0); let writeIndex = 0; while (writeIndex < out.length && audioChunks.length > 0) { const head = audioChunks[0]; const remaining = head.length - audioChunkOffset; if (remaining <= 0) { audioChunks.shift(); audioChunkOffset = 0; continue; } const take = Math.min(out.length - writeIndex, remaining); out.set(head.subarray(audioChunkOffset, audioChunkOffset + take), writeIndex); writeIndex += take; audioChunkOffset += take; if (audioChunkOffset >= head.length) { audioChunks.shift(); audioChunkOffset = 0; } } }; audioProcessorNode.connect(audioPlayCtx.destination); } } function appendAudioPcm(binary) { if (!audioPlayCtx) initAudioPlayback(); const samples = Math.floor(binary.byteLength / 2); if (samples <= 0) return; const src = new Int16Array(binary.buffer, binary.byteOffset, samples); const chunk = audioResampleInt16ToFloat32(src, AUDIO_SAMPLE_RATE, audioPlayCtx?.sampleRate || AUDIO_SAMPLE_RATE); if (chunk.length === 0) return; audioChunks.push(chunk); let buffered = -audioChunkOffset; for (const c of audioChunks) buffered += c.length; const rate = audioPlayCtx?.sampleRate || AUDIO_SAMPLE_RATE; const max = Math.max(AUDIO_PLAYBACK_FRAME, Math.round(rate * (AUDIO_MAX_BUFFER_MS / 1000))); while (buffered > max && audioChunks.length > 0) { const dropped = audioChunks.shift(); buffered -= dropped?.length || 0; audioChunkOffset = 0; } } const audioTransport = document.getElementById("audioTransport"); const webrtcAudio = document.getElementById("webrtcAudio"); let audioWhep = null; let audioP2P = null; function getAudioTransport() { return audioTransport ? String(audioTransport.value || "off") : "off"; } async function stopAudioWebrtc() { const w = audioWhep; audioWhep = null; if (w) { try { await w.stop(); } catch {} } const p = audioP2P; audioP2P = null; if (p) { try { await p.stop(); } catch {} } } async function startAudioWhep(whepPath) { await stopAudioWebrtc(); audioWhep = new WhepClient({ whepPath, audioEl: webrtcAudio, onState: (s) => console.debug("audio-webrtc[Relayed]: state", s), }); try { await audioWhep.start(); } catch (err) { console.warn("audio: WHEP start failed", err); audioWhep = null; } } function startAudioP2P() { if (!audioWs || audioWs.readyState !== WebSocket.OPEN) return; audioP2P = new P2PClient({ audioEl: webrtcAudio, send: (msg) => { if (audioWs && audioWs.readyState === WebSocket.OPEN) { audioWs.send(JSON.stringify(msg)); } }, onState: (s) => console.debug("audio-webrtc[P2P]: state", s), }); audioP2P.start().catch((err) => { console.warn("audio: P2P start failed", err); audioP2P = null; }); } function connectAudio() { if (audioWs && audioWs.readyState === WebSocket.OPEN) return; const mode = getAudioTransport(); // The WS-PCM path requires an AudioContext — only init it when we // actually need it (WebRTC plays through the