From a8f32a845faeeec2bbbe4e7f9700069e54f133f8 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:09:59 +0200 Subject: [PATCH] feat: enhance multi-session viewer functionality - Added session management features to synchronize media capture and input handling across active tabs. - Updated audio handling to respect session activity and mute states. - Improved clipboard functionality to only allow copying to local clipboard from the active session. - Refactored input capture logic to ensure it only activates for the active viewer tab. - Introduced a new `syncSessionMediaCapture` function to manage media input across sessions. --- web-nodejs/lib/remoteViewerPrefs.js | 98 +++++ web-nodejs/lib/sessionMediaSync.js | 18 + web-nodejs/public/js/lib/sessionMediaSync.js | 17 + web-nodejs/public/js/rdclient/audio.js | 48 ++- web-nodejs/public/js/rdclient/cdap-adapter.js | 87 ++++- web-nodejs/public/js/rdclient/client.js | 114 +++++- web-nodejs/public/js/rdclient/video.js | 30 +- web-nodejs/public/js/remote.js | 159 +++++++-- web-nodejs/public/js/remoteViewerPrefs.js | 75 ++++ .../tests/rdclient.sessionIsolation.test.js | 336 ++++++++++++++++++ web-nodejs/tests/remoteViewerPrefs.test.js | 37 ++ web-nodejs/views/layouts/viewer.ejs | 2 + 12 files changed, 956 insertions(+), 65 deletions(-) create mode 100644 web-nodejs/lib/remoteViewerPrefs.js create mode 100644 web-nodejs/lib/sessionMediaSync.js create mode 100644 web-nodejs/public/js/lib/sessionMediaSync.js create mode 100644 web-nodejs/public/js/remoteViewerPrefs.js create mode 100644 web-nodejs/tests/rdclient.sessionIsolation.test.js create mode 100644 web-nodejs/tests/remoteViewerPrefs.test.js diff --git a/web-nodejs/lib/remoteViewerPrefs.js b/web-nodejs/lib/remoteViewerPrefs.js new file mode 100644 index 00000000..408805df --- /dev/null +++ b/web-nodejs/lib/remoteViewerPrefs.js @@ -0,0 +1,98 @@ +'use strict'; + +/** FPS sent to the peer for inactive viewer tabs (RustDesk customFps / CDAP quality_set). */ +const BACKGROUND_FPS = 1; + +const QUALITY_TO_PRESET = { + Best: 'best', + Balanced: 'balanced', + Low: 'speed', +}; + +const PRESET_FPS = { + best: 60, + balanced: 30, + quality: 30, + speed: 60, +}; + +const DEFAULTS = { + quality: 'Best', + scale: 'fit', + codec: 'Auto', + adaptiveQuality: true, + backgroundFps: BACKGROUND_FPS, +}; + +function sanitizePrefs(raw) { + const source = raw && typeof raw === 'object' ? raw : {}; + const clean = { ...DEFAULTS }; + + if (['Best', 'Balanced', 'Low'].includes(source.quality)) { + clean.quality = source.quality; + } + if (['fit', 'fill', '1:1', 'stretch'].includes(source.scale)) { + clean.scale = source.scale; + } + if (typeof source.codec === 'string' && source.codec.length <= 16) { + clean.codec = source.codec; + } + if (typeof source.adaptiveQuality === 'boolean') { + clean.adaptiveQuality = source.adaptiveQuality; + } + const bg = Number(source.backgroundFps); + if (Number.isFinite(bg) && bg >= 1 && bg <= 5) { + clean.backgroundFps = Math.round(bg); + } + + return clean; +} + +function getPresetForQuality(quality) { + return QUALITY_TO_PRESET[quality] || 'balanced'; +} + +function getActiveFpsForQuality(quality) { + return PRESET_FPS[getPresetForQuality(quality)] || 30; +} + +function storageKey(userId) { + const id = userId != null ? String(userId) : 'anonymous'; + return 'betterdesk_remote_prefs_' + id; +} + +function loadRemoteViewerPrefs(userId, storage) { + if (!storage || typeof storage.getItem !== 'function') { + return { ...DEFAULTS }; + } + try { + const raw = storage.getItem(storageKey(userId)); + if (!raw) return { ...DEFAULTS }; + return sanitizePrefs(JSON.parse(raw)); + } catch (_) { + return { ...DEFAULTS }; + } +} + +function saveRemoteViewerPrefs(userId, prefs, storage) { + const clean = sanitizePrefs(prefs); + if (storage && typeof storage.setItem === 'function') { + try { + storage.setItem(storageKey(userId), JSON.stringify(clean)); + } catch (_) { /* ignore */ } + } + return clean; +} + +module.exports = { + BACKGROUND_FPS, + DEFAULTS, + QUALITY_TO_PRESET, + PRESET_FPS, + sanitizePrefs, + getPresetForQuality, + getActiveFpsForQuality, + storageKey, + loadRemoteViewerPrefs, + saveRemoteViewerPrefs, +}; diff --git a/web-nodejs/lib/sessionMediaSync.js b/web-nodejs/lib/sessionMediaSync.js new file mode 100644 index 00000000..786980be --- /dev/null +++ b/web-nodejs/lib/sessionMediaSync.js @@ -0,0 +1,18 @@ +'use strict'; + +/** + * Scope keyboard/mouse capture, inbound clipboard, and audio to the active + * streaming tab. Used by remote.js and unit tests. + * + * @param {Map} sessions + * @param {string|null} activeSessionId + */ +function syncSessionMediaCapture(sessions, activeSessionId) { + for (const session of sessions.values()) { + if (!session.client || typeof session.client.setSessionActive !== 'function') continue; + const active = session.deviceId === activeSessionId && session.state === 'streaming'; + session.client.setSessionActive(active); + } +} + +module.exports = { syncSessionMediaCapture }; diff --git a/web-nodejs/public/js/lib/sessionMediaSync.js b/web-nodejs/public/js/lib/sessionMediaSync.js new file mode 100644 index 00000000..8251f237 --- /dev/null +++ b/web-nodejs/public/js/lib/sessionMediaSync.js @@ -0,0 +1,17 @@ +/** + * Multi-session viewer: gate input/clipboard/audio to the active tab. + * Shared with web-nodejs/lib/sessionMediaSync.js (keep in sync). + */ +(function (global) { + 'use strict'; + + function syncSessionMediaCapture(sessions, activeSessionId) { + for (const session of sessions.values()) { + if (!session.client || typeof session.client.setSessionActive !== 'function') continue; + const active = session.deviceId === activeSessionId && session.state === 'streaming'; + session.client.setSessionActive(active); + } + } + + global.syncSessionMediaCapture = syncSessionMediaCapture; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/web-nodejs/public/js/rdclient/audio.js b/web-nodejs/public/js/rdclient/audio.js index e44f5c6a..c8235db5 100644 --- a/web-nodejs/public/js/rdclient/audio.js +++ b/web-nodejs/public/js/rdclient/audio.js @@ -14,8 +14,10 @@ class RDAudio { this.sampleRate = 48000; /** @type {number} Number of channels */ this.channels = 2; - /** @type {boolean} */ - this.enabled = true; + /** @type {boolean} Toolbar mute toggle */ + this._userMuted = false; + /** @type {boolean} Active tab in multi-session viewer */ + this._sessionActive = true; /** @type {boolean} */ this.initialized = false; /** @type {number} Next scheduled playback time */ @@ -64,8 +66,12 @@ class RDAudio { latencyHint: 'interactive' }); - // Expose for video.js retryPlay() to resume - window._rdAudioCtx = this.audioCtx; + // Expose for video.js retryPlay() to resume this session's context + this._resumeHook = () => { + if (this.audioCtx && this.audioCtx.state === 'suspended') { + this.audioCtx.resume(); + } + }; // Create gain node for volume control this.gainNode = this.audioCtx.createGain(); @@ -171,7 +177,7 @@ class RDAudio { * @param {Object} audioFrame - { data: Uint8Array, timestamp: number } */ play(audioFrame) { - if (!this.initialized || !this.enabled || !this.audioCtx) return; + if (!this.initialized || !this._sessionActive || this._userMuted || !this.audioCtx) return; // Resume audio context if suspended (auto-play policy) if (this.audioCtx.state === 'suspended') { @@ -243,20 +249,32 @@ class RDAudio { */ setVolume(vol) { this.volume = Math.max(0, Math.min(1, vol)); - if (this.gainNode) { - this.gainNode.gain.value = this.volume; - } + this._updateGain(); } /** - * Mute/unmute audio + * Mute/unmute audio (toolbar toggle) * @param {boolean} muted */ setMuted(muted) { - this.enabled = !muted; - if (this.gainNode) { - this.gainNode.gain.value = muted ? 0 : this.volume; - } + this._userMuted = !!muted; + this._updateGain(); + } + + /** + * Enable/disable playback for inactive viewer tabs + * @param {boolean} active + */ + setSessionActive(active) { + this._sessionActive = active !== false; + this._updateGain(); + } + + /** @private */ + _updateGain() { + if (!this.gainNode) return; + const silent = this._userMuted || !this._sessionActive; + this.gainNode.gain.value = silent ? 0 : this.volume; } /** @@ -265,7 +283,7 @@ class RDAudio { getStats() { return { initialized: this.initialized, - enabled: this.enabled, + enabled: this._sessionActive && !this._userMuted, sampleRate: this.sampleRate, channels: this.channels, framesPlayed: this.framesPlayed, @@ -298,7 +316,7 @@ class RDAudio { } } this.audioCtx = null; - window._rdAudioCtx = null; + this._resumeHook = null; this.gainNode = null; this.initialized = false; this.framesPlayed = 0; diff --git a/web-nodejs/public/js/rdclient/cdap-adapter.js b/web-nodejs/public/js/rdclient/cdap-adapter.js index 6f24511f..e9251042 100644 --- a/web-nodejs/public/js/rdclient/cdap-adapter.js +++ b/web-nodejs/public/js/rdclient/cdap-adapter.js @@ -200,6 +200,11 @@ this._sessionId = null; this._lastErrorText = ''; this._lastErrorAt = 0; + this._sessionActive = true; + this._clipboardToLocalEnabled = true; + this._targetFps = this._normaliseFps(opts.fps || 60); + this._backgroundFps = 1; + this._streamThrottledActive = null; // Stats counters this._frameCount = 0; @@ -337,13 +342,22 @@ this._send({ type: 'quality_set', quality: q }); } setQualityPreset(preset) { - // 'best' | 'balanced' | 'speed' - const map = { best: 92, balanced: 75, speed: 50 }; - const q = map[String(preset || '').toLowerCase()] || 75; - this._send({ type: 'quality_set', quality: q }); + const presets = { + best: { quality: 92, fps: 60 }, + balanced: { quality: 75, fps: 30 }, + speed: { quality: 50, fps: 60 }, + }; + const p = presets[String(preset || '').toLowerCase()] || presets.balanced; + this._targetFps = p.fps; + this.opts.qualityPreset = preset; + this._send({ type: 'quality_set', quality: p.quality, fps: p.fps }); } setFps(fps) { - this._send({ type: 'quality_set', fps: this._normaliseFps(fps) }); + const n = this._normaliseFps(fps); + if (this._sessionActive) { + this._targetFps = n; + } + this._send({ type: 'quality_set', fps: n }); } setScaleMode(mode) { try { this.renderer.setScaleMode(mode); } catch { /* noop */ } @@ -356,6 +370,59 @@ setBlockInput(b) { this._send({ type: 'block_input', enabled: !!b }); } setAudioMuted(_b) { /* audio is handled via separate /audio WS */ } + /** + * Mark whether this client is the active tab in the multi-session viewer. + * @param {boolean} active + */ + setSessionActive(active) { + const next = !!active; + const changed = next !== this._sessionActive; + this._sessionActive = next; + this._clipboardToLocalEnabled = next; + this._syncInputCapture(); + if (this._connected) { + this._syncStreamThrottle(); + } else if (changed) { + this._syncStreamThrottle(); + } + } + + setBackgroundFps(fps) { + const n = Number(fps); + if (Number.isFinite(n) && n >= 1 && n <= 5) { + this._backgroundFps = Math.round(n); + } + } + + /** @private */ + _syncStreamThrottle() { + if (!this._connected) return; + const wantActive = this._sessionActive; + if (this._streamThrottledActive === wantActive) return; + this._streamThrottledActive = wantActive; + if (wantActive) { + if (this._video && this._video.setBackgroundMode) { + this._video.setBackgroundMode(false); + } + this.setFps(this._targetFps || this._normaliseFps(this.opts.fps || 60)); + this._requestKeyframe(); + } else { + if (this._video && this._video.setBackgroundMode) { + this._video.setBackgroundMode(true); + } + this._send({ type: 'quality_set', fps: this._backgroundFps || 1 }); + } + } + + /** @private */ + _syncInputCapture() { + if (this._sessionActive && this._connected) { + this._bindInput(); + } else { + this._unbindInput(); + } + } + requestKeyframe() { this._send({ type: 'keyframe_request' }); } @@ -502,11 +569,15 @@ this._sessionId = msg.session_id || null; this._connected = true; this._setState('streaming'); - this._bindInput(); + this._syncInputCapture(); this._startStats(); this._emit('login_success'); this._emit('session_start'); this._emit('log', 'Streaming'); + if (!this._sessionActive) { + this._streamThrottledActive = null; + this._syncStreamThrottle(); + } console.log('[CDAP] ready, session=', this._sessionId); break; @@ -693,6 +764,7 @@ * @param {ArrayBuffer} buf */ _feedVideoFrame(buf) { + if (!this._sessionActive) return; const bytes = new Uint8Array(buf); if (bytes.length < 2) return; const isKey = (bytes[0] & 1) === 1; @@ -758,6 +830,7 @@ } _renderEncodedFrame(msg) { + if (!this._sessionActive) return; const fmt = msg.format || 'jpeg'; const src = msg.data.startsWith('data:') ? msg.data : `data:image/${fmt};base64,${msg.data}`; this._frameCount++; @@ -791,7 +864,7 @@ // Mirror device → operator clipboard when the agent allows it. const text = msg.text; if (!text) return; - if (navigator.clipboard && navigator.clipboard.writeText) { + if (this._clipboardToLocalEnabled && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).catch(() => { /* permission denied */ }); } this._emit('clipboard', text); diff --git a/web-nodejs/public/js/rdclient/client.js b/web-nodejs/public/js/rdclient/client.js index 5425c14b..21c07521 100644 --- a/web-nodejs/public/js/rdclient/client.js +++ b/web-nodejs/public/js/rdclient/client.js @@ -72,6 +72,14 @@ class RDClient { // Settings this.renderer.setScaleMode(opts.scaleMode || 'fit'); + + // Multi-session viewer: remote.js toggles these when switching tabs + this._sessionActive = true; + this._clipboardToLocalEnabled = true; + this._savedActiveFps = null; + this._backgroundFps = 1; + this._streamThrottledActive = null; + this.video.getAudioContext = () => this.audio.audioCtx; } get state() { return this._state; } @@ -978,8 +986,8 @@ class RDClient { const text = decoder.decode(clipboard.content); this._emit('clipboard', text); - // Copy to local clipboard if permitted - if (navigator.clipboard && navigator.clipboard.writeText) { + // Copy to local clipboard only for the active viewer tab + if (this._clipboardToLocalEnabled && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).catch(() => { // Clipboard write permission denied - ignore }); @@ -1077,8 +1085,7 @@ class RDClient { // Start render loop this.renderer.startRenderLoop(); - // Start input capture - this.input.start(); + // Input capture is started by remote.js via setSessionActive() for the active tab only // Initialize audio (will actually start on first audio data) if (!this.opts.disableAudio && RDAudio.isSupported()) { @@ -1089,6 +1096,7 @@ class RDClient { // Tell peer our desired FPS and image quality after session establishment const fps = this.opts.fps || 60; + this._savedActiveFps = fps; const quality = this.opts.imageQuality || 'Best'; this._sendPeerMessage(this.proto.buildOptionMisc({ customFps: fps, @@ -1157,6 +1165,10 @@ class RDClient { } this._emit('session_start'); + if (!this._sessionActive) { + this._streamThrottledActive = null; + this._syncStreamThrottle(); + } } /** @@ -1191,6 +1203,7 @@ class RDClient { this._adaptiveInterval = setInterval(() => { if (this._state !== 'streaming') return; + if (!this._sessionActive) return; if (this._adaptivePaused) return; // user took manual control of quality/codec const stats = this.video.getStats(); const fps = stats.videoFps || 0; @@ -1610,8 +1623,9 @@ class RDClient { var c = config[preset] || config.balanced; this._adaptivePaused = true; // explicit user choice — stop auto-adjusting - this._sendPeerMessage(this.proto.buildOptionMisc({ imageQuality: c.imageQuality, customFps: c.customFps })); + this._savedActiveFps = c.customFps; this.opts.qualityPreset = preset; + this._sendPeerMessage(this.proto.buildOptionMisc({ imageQuality: c.imageQuality, customFps: c.customFps })); this._emit('quality_changed', preset); } @@ -1733,14 +1747,94 @@ class RDClient { */ setViewOnly(on) { this._viewOnly = on; - if (on) { - this.input.stop(); - } else if (this._state === 'streaming') { - this.input.start(); - } + this._syncInputCapture(); this._emit('view_only', on); } + /** + * Mark whether this client is the active tab in the multi-session viewer. + * Gates keyboard/mouse capture, inbound clipboard, and audio playback. + * @param {boolean} active + */ + setSessionActive(active) { + const next = !!active; + const changed = next !== this._sessionActive; + this._sessionActive = next; + this._clipboardToLocalEnabled = next; + if (this.audio.setSessionActive) { + this.audio.setSessionActive(next); + } + this._syncInputCapture(); + if (this._state === 'streaming') { + this._syncStreamThrottle(); + } else if (changed) { + this._syncStreamThrottle(); + } + } + + /** + * Background FPS for inactive viewer tabs (RustDesk customFps option). + * @param {number} fps + */ + setBackgroundFps(fps) { + const n = Number(fps); + if (Number.isFinite(n) && n >= 1 && n <= 5) { + this._backgroundFps = Math.round(n); + } + } + + /** @private Target FPS for the active tab based on quality preset */ + _getActiveStreamFps() { + if (this._savedActiveFps) return this._savedActiveFps; + const preset = this.opts.qualityPreset || 'best'; + const map = { speed: 60, balanced: 30, quality: 30, best: 60 }; + return map[preset] || this.opts.fps || 60; + } + + /** @private Throttle peer encode rate and pause local decode when tab is hidden */ + _syncStreamThrottle() { + if (this._state !== 'streaming') return; + const wantActive = this._sessionActive; + if (this._streamThrottledActive === wantActive) return; + this._streamThrottledActive = wantActive; + if (wantActive) { + this._resumeActiveStream(); + } else { + this._throttleBackgroundStream(); + } + } + + _throttleBackgroundStream() { + if (!this._savedActiveFps) { + this._savedActiveFps = this._getActiveStreamFps(); + } + this.renderer.stopRenderLoop(); + if (this.video.setBackgroundMode) { + this.video.setBackgroundMode(true); + } + this.setCustomFps(this._backgroundFps || 1); + } + + _resumeActiveStream() { + if (this.video.setBackgroundMode) { + this.video.setBackgroundMode(false); + } + this.renderer.startRenderLoop(); + const fps = this._getActiveStreamFps(); + this.setCustomFps(fps); + this._sendPeerMessage(this.proto.buildMisc('refreshVideo', true)); + } + + /** @private Sync input listeners with session/tab and view-only state */ + _syncInputCapture() { + const shouldCapture = this._sessionActive && !this._viewOnly && this._state === 'streaming'; + if (shouldCapture) { + this.input.start(); + } else { + this.input.stop(); + } + } + /** @returns {boolean} Whether view-only mode is active */ get viewOnly() { return this._viewOnly || false; } diff --git a/web-nodejs/public/js/rdclient/video.js b/web-nodejs/public/js/rdclient/video.js index 0849ca14..852ed7f2 100644 --- a/web-nodejs/public/js/rdclient/video.js +++ b/web-nodejs/public/js/rdclient/video.js @@ -15,7 +15,10 @@ class RDVideo { /** @type {string|null} Current codec */ this.currentCodec = null; /** @type {Function|null} Callback for decoded frames */ + /** @type {Function|null} Callback for decoded frames */ this.onFrame = null; + /** @type {boolean} Skip decode/render while viewer tab is in background */ + this._backgroundMode = false; /** @type {Function|null} Callback for errors */ this.onError = null; /** @type {number} Decoded frame counter */ @@ -348,9 +351,12 @@ class RDVideo { console.warn('[RDVideo] retryPlay failed:', err.message); }); } - // Also resume AudioContext if it exists - if (window._rdAudioCtx && window._rdAudioCtx.state === 'suspended') { - window._rdAudioCtx.resume(); + // Also resume AudioContext if it exists for this session + if (typeof this.getAudioContext === 'function') { + const ctx = this.getAudioContext(); + if (ctx && ctx.state === 'suspended') { + ctx.resume(); + } } } @@ -554,6 +560,15 @@ class RDVideo { this.droppedFrames = savedDropped; } + /** + * Pause local decode/render while the session tab is inactive. + * The peer should already be throttled via customFps / quality_set. + * @param {boolean} on + */ + setBackgroundMode(on) { + this._backgroundMode = !!on; + } + /** * Feed an encoded frame to the decoder * @param {Object} frameData - { data: Uint8Array, key: boolean, pts: number, codec: string } @@ -563,6 +578,11 @@ class RDVideo { return; } + if (this._backgroundMode) { + this.droppedFrames++; + return; + } + // Switch codec if needed if (frameData.codec && frameData.codec !== this.currentCodec) { await this.init(frameData.codec); @@ -699,6 +719,10 @@ class RDVideo { } if (this.onFrame) { + if (this._backgroundMode) { + frame.close(); + return; + } this.onFrame(frame); } else { // Must close frame if not consumed diff --git a/web-nodejs/public/js/remote.js b/web-nodejs/public/js/remote.js index 0628a296..63b39607 100644 --- a/web-nodejs/public/js/remote.js +++ b/web-nodejs/public/js/remote.js @@ -55,6 +55,77 @@ const sessions = new Map(); // deviceId → SessionInfo let activeSessionId = null; + const Prefs = window.RemoteViewerPrefs || {}; + const globalViewerPrefs = typeof Prefs.loadRemoteViewerPrefs === 'function' + ? Prefs.loadRemoteViewerPrefs(window.BetterDesk?.user?.id) + : { quality: 'Best', scale: 'fit', codec: 'Auto', adaptiveQuality: true, backgroundFps: 1 }; + + function cloneViewerPrefs(prefs) { + return Object.assign({ + quality: 'Best', + scale: 'fit', + codec: 'Auto', + adaptiveQuality: true, + backgroundFps: 1, + }, prefs || globalViewerPrefs); + } + + function persistGlobalViewerPrefs(prefs) { + Object.assign(globalViewerPrefs, prefs); + if (typeof Prefs.saveRemoteViewerPrefs === 'function') { + Prefs.saveRemoteViewerPrefs(window.BetterDesk?.user?.id, globalViewerPrefs); + } + } + + function buildClientOpts(session) { + const prefs = session.viewerPrefs || globalViewerPrefs; + const userName = (window.BetterDesk.user && (window.BetterDesk.user.display_name || window.BetterDesk.user.username)) || 'BetterDesk Web'; + const activeFps = typeof Prefs.getActiveFpsForQuality === 'function' + ? Prefs.getActiveFpsForQuality(prefs.quality) + : 60; + return { + deviceId: session.deviceId, + serverPubKey: window.BetterDesk.serverPubKey || '', + myName: userName, + scaleMode: prefs.scale || 'fit', + fps: activeFps, + imageQuality: prefs.quality || 'Best', + qualityPreset: typeof Prefs.getPresetForQuality === 'function' + ? Prefs.getPresetForQuality(prefs.quality) + : 'best', + adaptiveQuality: prefs.adaptiveQuality !== false, + preferCodec: prefs.codec || 'Auto', + disableAudio: false, + }; + } + + function applyViewerPrefsToClient(client, prefs) { + if (!client || !prefs) return; + if (typeof client.setBackgroundFps === 'function') { + client.setBackgroundFps(prefs.backgroundFps || 1); + } + if (client._state === 'streaming' || client.state === 'streaming') { + const preset = typeof Prefs.getPresetForQuality === 'function' + ? Prefs.getPresetForQuality(prefs.quality) + : ({ Best: 'best', Balanced: 'balanced', Low: 'speed' }[prefs.quality] || 'best'); + if (typeof client.setQualityPreset === 'function') { + client.setQualityPreset(preset); + } + if (typeof client.setScaleMode === 'function') { + client.setScaleMode(prefs.scale || 'fit'); + } + if (prefs.codec && prefs.codec !== 'Auto' && typeof client.setCodec === 'function') { + client.setCodec(prefs.codec); + } + } + } + + function updateSessionViewerPref(session, patch) { + if (!session) return; + session.viewerPrefs = Object.assign({}, session.viewerPrefs || cloneViewerPrefs(), patch); + persistGlobalViewerPrefs(session.viewerPrefs); + } + /** * Session info wrapper for a single remote connection */ @@ -88,6 +159,7 @@ this.latency = 0; this.lastStats = null; this.audioMuted = false; + this.viewerPrefs = cloneViewerPrefs(); this.mediaRecorder = null; this.recordedChunks = []; } @@ -298,23 +370,8 @@ if (isInsecure) showHttpWarningBanner(); } - // Create RDClient — start conservative; AdaptiveQuality promotes when the - // pipeline proves it can keep up (prevents 3–7 FPS stalls on weaker CPUs/JMuxer). - const userName = (window.BetterDesk.user && (window.BetterDesk.user.display_name || window.BetterDesk.user.username)) || 'BetterDesk Web'; - session.client = createTransportClient(session.canvas, { - deviceId: deviceId, - serverPubKey: window.BetterDesk.serverPubKey || '', - myName: userName, - scaleMode: 'fit', - fps: 30, - imageQuality: 'Best', - adaptiveQuality: true, - disableAudio: false - }); - - wireSessionEvents(session); - wireSessionDomEvents(session); - wireFileTransferEvents(session); + // Create transport client from saved operator prefs (Best = 60fps). + wireNewClient(session); sessions.set(deviceId, session); createTab(deviceId, deviceName); switchSession(deviceId); @@ -326,6 +383,16 @@ }); } + function wireNewClient(session) { + session.client = createTransportClient(session.canvas, buildClientOpts(session)); + if (typeof session.client.setBackgroundFps === 'function') { + session.client.setBackgroundFps((session.viewerPrefs || globalViewerPrefs).backgroundFps || 1); + } + wireSessionEvents(session); + wireSessionDomEvents(session); + wireFileTransferEvents(session); + } + function switchSession(deviceId) { if (!sessions.has(deviceId)) return; activeSessionId = deviceId; @@ -342,6 +409,7 @@ // Sync toolbar state syncToolbarToSession(session); + syncToolbarFromSession(session); if (session.state === 'streaming') { session.canvas.focus(); @@ -350,6 +418,12 @@ } else { setToolbarAutoHide(false); } + + syncSessionMediaCapture(); + + if (session.state === 'streaming' && session.client) { + session.client.setAudioMuted(session.audioMuted); + } } function closeSession(deviceId) { @@ -378,6 +452,8 @@ } else { returnToDevices(); } + } else { + syncSessionMediaCapture(); } } @@ -391,19 +467,7 @@ if (spinner) spinner.style.display = 'block'; session.statusText.textContent = _('remote.connecting'); - const userName = (window.BetterDesk.user && (window.BetterDesk.user.display_name || window.BetterDesk.user.username)) || 'BetterDesk Web'; - session.client = createTransportClient(session.canvas, { - deviceId: session.deviceId, - serverPubKey: window.BetterDesk.serverPubKey || '', - myName: userName, - scaleMode: 'fit', - fps: 30, - imageQuality: 'Best', - adaptiveQuality: true, - disableAudio: false - }); - wireSessionEvents(session); - wireFileTransferEvents(session); + wireNewClient(session); session.client.renderer.resize(); session.client.connect().catch(err => { setSessionStatus(session, 'error', err.message); @@ -506,6 +570,10 @@ session.connectionOverlay.style.display = 'none'; session.passwordOverlay.style.display = 'none'; session.client.renderer.resize(); + if (isActive(session)) { + applyViewerPrefsToClient(session.client, session.viewerPrefs); + } + syncSessionMediaCapture(); if (isActive(session)) { session.canvas.focus(); setToolbarAutoHide(true); @@ -907,6 +975,16 @@ return session.deviceId === activeSessionId; } + /** + * Keep keyboard/mouse capture, inbound clipboard, and audio scoped to the + * active streaming tab so background sessions cannot receive input. + */ + function syncSessionMediaCapture() { + if (typeof window.syncSessionMediaCapture === 'function') { + window.syncSessionMediaCapture(sessions, activeSessionId); + } + } + function handleSessionState(session, state) { switch (state) { case 'connecting': @@ -919,6 +997,7 @@ session.connectionOverlay.style.display = 'none'; session.passwordOverlay.style.display = 'none'; session.panel.classList.add('streaming'); + syncSessionMediaCapture(); if (isActive(session)) setToolbarAutoHide(true); break; case 'disconnected': @@ -982,6 +1061,20 @@ } } + function syncToolbarFromSession(session) { + if (!session || !session.viewerPrefs) return; + const p = session.viewerPrefs; + document.querySelectorAll('.quality-item').forEach(function (btn) { + btn.classList.toggle('active', btn.dataset.quality === p.quality); + }); + document.querySelectorAll('.scale-item').forEach(function (btn) { + btn.classList.toggle('active', btn.dataset.scale === p.scale); + }); + document.querySelectorAll('.codec-item').forEach(function (btn) { + btn.classList.toggle('active', btn.dataset.codec === p.codec); + }); + } + // ---- Stats display ---- function updateStats(stats, latency) { @@ -1132,7 +1225,9 @@ document.querySelectorAll('.quality-item').forEach(btn => { btn.addEventListener('click', function () { var preset = qualityToPreset[this.dataset.quality] || 'balanced'; + var session = getActiveSession(); withClient(c => c.setQualityPreset(preset)); + if (session) updateSessionViewerPref(session, { quality: this.dataset.quality }); document.querySelectorAll('.quality-item').forEach(b => b.classList.remove('active')); this.classList.add('active'); closeAllDropdowns(); @@ -1142,7 +1237,9 @@ // Scale items document.querySelectorAll('.scale-item').forEach(btn => { btn.addEventListener('click', function () { + var session = getActiveSession(); withClient(c => c.setScaleMode(this.dataset.scale)); + if (session) updateSessionViewerPref(session, { scale: this.dataset.scale }); document.querySelectorAll('.scale-item').forEach(b => b.classList.remove('active')); this.classList.add('active'); closeAllDropdowns(); @@ -1153,7 +1250,9 @@ document.querySelectorAll('.codec-item').forEach(btn => { btn.addEventListener('click', function () { if (this.classList.contains('disabled')) return; + var session = getActiveSession(); withClient(c => c.setCodec(this.dataset.codec)); + if (session) updateSessionViewerPref(session, { codec: this.dataset.codec }); document.querySelectorAll('.codec-item').forEach(b => b.classList.remove('active')); this.classList.add('active'); closeAllDropdowns(); diff --git a/web-nodejs/public/js/remoteViewerPrefs.js b/web-nodejs/public/js/remoteViewerPrefs.js new file mode 100644 index 00000000..43b80208 --- /dev/null +++ b/web-nodejs/public/js/remoteViewerPrefs.js @@ -0,0 +1,75 @@ +/** + * Remote viewer preferences (localStorage, per operator account). + * Logic mirrored in web-nodejs/lib/remoteViewerPrefs.js for unit tests. + */ +(function (global) { + 'use strict'; + + var BACKGROUND_FPS = 1; + var QUALITY_TO_PRESET = { Best: 'best', Balanced: 'balanced', Low: 'speed' }; + var PRESET_FPS = { best: 60, balanced: 30, quality: 30, speed: 60 }; + var DEFAULTS = { + quality: 'Best', + scale: 'fit', + codec: 'Auto', + adaptiveQuality: true, + backgroundFps: BACKGROUND_FPS, + }; + + function sanitizePrefs(raw) { + var source = raw && typeof raw === 'object' ? raw : {}; + var clean = Object.assign({}, DEFAULTS); + if (['Best', 'Balanced', 'Low'].indexOf(source.quality) >= 0) clean.quality = source.quality; + if (['fit', 'fill', '1:1', 'stretch'].indexOf(source.scale) >= 0) clean.scale = source.scale; + if (typeof source.codec === 'string' && source.codec.length <= 16) clean.codec = source.codec; + if (typeof source.adaptiveQuality === 'boolean') clean.adaptiveQuality = source.adaptiveQuality; + var bg = Number(source.backgroundFps); + if (Number.isFinite(bg) && bg >= 1 && bg <= 5) clean.backgroundFps = Math.round(bg); + return clean; + } + + function storageKey(userId) { + var id = userId != null ? String(userId) : 'anonymous'; + return 'betterdesk_remote_prefs_' + id; + } + + function loadRemoteViewerPrefs(userId) { + try { + var raw = localStorage.getItem(storageKey(userId)); + if (!raw) return Object.assign({}, DEFAULTS); + return sanitizePrefs(JSON.parse(raw)); + } catch (_) { + return Object.assign({}, DEFAULTS); + } + } + + var _saveTimer = null; + function saveRemoteViewerPrefs(userId, prefs) { + var clean = sanitizePrefs(prefs); + clearTimeout(_saveTimer); + _saveTimer = setTimeout(function () { + try { + localStorage.setItem(storageKey(userId), JSON.stringify(clean)); + } catch (_) { /* localStorage disabled */ } + }, 400); + return clean; + } + + function getPresetForQuality(quality) { + return QUALITY_TO_PRESET[quality] || 'balanced'; + } + + function getActiveFpsForQuality(quality) { + return PRESET_FPS[getPresetForQuality(quality)] || 30; + } + + global.RemoteViewerPrefs = { + BACKGROUND_FPS: BACKGROUND_FPS, + DEFAULTS: DEFAULTS, + sanitizePrefs: sanitizePrefs, + loadRemoteViewerPrefs: loadRemoteViewerPrefs, + saveRemoteViewerPrefs: saveRemoteViewerPrefs, + getPresetForQuality: getPresetForQuality, + getActiveFpsForQuality: getActiveFpsForQuality, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/web-nodejs/tests/rdclient.sessionIsolation.test.js b/web-nodejs/tests/rdclient.sessionIsolation.test.js new file mode 100644 index 00000000..a087c4db --- /dev/null +++ b/web-nodejs/tests/rdclient.sessionIsolation.test.js @@ -0,0 +1,336 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const { syncSessionMediaCapture } = require('../lib/sessionMediaSync'); + +function loadBrowserScript(relativePath, extraGlobals) { + const filename = path.join(__dirname, '..', relativePath); + const sandbox = { + console, + window: {}, + globalThis: {}, + ...extraGlobals, + }; + sandbox.window = sandbox; + sandbox.globalThis = sandbox; + vm.runInNewContext(fs.readFileSync(filename, 'utf8'), sandbox, { filename }); + return sandbox; +} + +describe('syncSessionMediaCapture', () => { + it('activates only the active streaming session', () => { + const clientA = { setSessionActive: jest.fn() }; + const clientB = { setSessionActive: jest.fn() }; + const sessions = new Map([ + ['device-a', { deviceId: 'device-a', state: 'streaming', client: clientA }], + ['device-b', { deviceId: 'device-b', state: 'streaming', client: clientB }], + ]); + + syncSessionMediaCapture(sessions, 'device-b'); + + expect(clientA.setSessionActive).toHaveBeenCalledWith(false); + expect(clientB.setSessionActive).toHaveBeenCalledWith(true); + }); + + it('does not activate a non-streaming tab even when selected', () => { + const client = { setSessionActive: jest.fn() }; + const sessions = new Map([ + ['device-a', { deviceId: 'device-a', state: 'connecting', client }], + ]); + + syncSessionMediaCapture(sessions, 'device-a'); + + expect(client.setSessionActive).toHaveBeenCalledWith(false); + }); + + it('ignores sessions without a transport client', () => { + const sessions = new Map([ + ['device-a', { deviceId: 'device-a', state: 'streaming', client: null }], + ]); + + expect(() => syncSessionMediaCapture(sessions, 'device-a')).not.toThrow(); + }); +}); + +describe('RDAudio session isolation', () => { + let RDAudio; + + beforeAll(() => { + class MockGainNode { + constructor() { this.gain = { value: 1 }; } + connect() {} + } + class MockAudioContext { + constructor() { + this.state = 'running'; + this.currentTime = 0; + this.destination = {}; + } + createGain() { return new MockGainNode(); } + createBuffer() { + return { getChannelData: () => new Float32Array(1) }; + } + createBufferSource() { + return { connect() {}, start() {} }; + } + resume() { return Promise.resolve(); } + close() { this.state = 'closed'; } + } + + const sandbox = loadBrowserScript('public/js/rdclient/audio.js', { + AudioContext: MockAudioContext, + webkitAudioContext: MockAudioContext, + AudioDecoder: undefined, + }); + RDAudio = sandbox.RDAudio; + }); + + it('skips playback when the session tab is inactive', async () => { + const audio = new RDAudio(); + await audio.init(); + audio.setSessionActive(false); + + audio.play({ data: new Uint8Array([0, 0, 1, 0]), timestamp: 0 }); + + expect(audio.framesPlayed).toBe(0); + }); + + it('respects toolbar mute independently of tab activity', async () => { + const audio = new RDAudio(); + await audio.init(); + audio.setSessionActive(true); + audio.setMuted(true); + + expect(audio.gainNode.gain.value).toBe(0); + + audio.setSessionActive(false); + expect(audio.gainNode.gain.value).toBe(0); + + audio.setSessionActive(true); + audio.setMuted(false); + expect(audio.gainNode.gain.value).toBe(1); + }); +}); + +describe('RDInput multi-session keyboard isolation', () => { + let RDInput; + let keydownHandlers; + + beforeAll(() => { + keydownHandlers = []; + + const documentListeners = { keydown: [], keyup: [], pointerlockchange: [] }; + const document = { + activeElement: null, + addEventListener(type, fn) { + if (documentListeners[type]) documentListeners[type].push(fn); + }, + removeEventListener(type, fn) { + if (!documentListeners[type]) return; + documentListeners[type] = documentListeners[type].filter((h) => h !== fn); + }, + exitPointerLock() {}, + _listeners: documentListeners, + _dispatch(type, event) { + for (const fn of documentListeners[type] || []) fn(event); + }, + }; + + function makeCanvas() { + const listeners = {}; + return { + tabIndex: -1, + addEventListener(type, fn) { listeners[type] = fn; }, + removeEventListener(type, fn) { if (listeners[type] === fn) delete listeners[type]; }, + focus() { document.activeElement = this; }, + }; + } + + const sandbox = loadBrowserScript('public/js/rdclient/input.js', { + document, + RDProtocol: {}, + makeCanvas, + }); + RDInput = sandbox.RDInput; + sandbox.makeCanvas = makeCanvas; + sandbox.document = document; + }); + + function makeInput(sendMessage) { + const canvas = { + tabIndex: -1, + listeners: {}, + addEventListener(type, fn) { this.listeners[type] = fn; }, + removeEventListener(type, fn) { if (this.listeners[type] === fn) delete this.listeners[type]; }, + focus() {}, + }; + const renderer = { mapCoords: (x, y) => ({ x, y }) }; + return new RDInput(canvas, renderer, sendMessage); + } + + it('does not send keys after stop() even though the handler stays registered', () => { + const sendA = jest.fn(); + const sendB = jest.fn(); + const inputA = makeInput(sendA); + const inputB = makeInput(sendB); + + inputA.start(); + inputB.start(); + inputA.stop(); + + const event = { + code: 'KeyA', + key: 'a', + repeat: false, + preventDefault() {}, + stopPropagation() {}, + ctrlKey: false, + altKey: false, + metaKey: false, + }; + + inputA._handleKeyDown(event); + inputB._handleKeyDown(event); + + expect(sendA).not.toHaveBeenCalled(); + expect(sendB).toHaveBeenCalled(); + }); +}); + +describe('RDClient setSessionActive contract', () => { + function makeClientStub() { + const input = { + start: jest.fn(), + stop: jest.fn(), + }; + const audio = { setSessionActive: jest.fn() }; + const renderer = { stopRenderLoop: jest.fn(), startRenderLoop: jest.fn() }; + const video = { setBackgroundMode: jest.fn() }; + const client = { + _sessionActive: true, + _viewOnly: false, + _state: 'streaming', + _clipboardToLocalEnabled: true, + _streamThrottledActive: null, + _backgroundFps: 1, + _savedActiveFps: 60, + input, + audio, + renderer, + video, + setCustomFps: jest.fn(), + _sendPeerMessage: jest.fn(), + proto: { buildMisc: () => ({ misc: {} }) }, + setSessionActive(active) { + const next = !!active; + this._sessionActive = next; + this._clipboardToLocalEnabled = next; + this.audio.setSessionActive(next); + this._syncInputCapture(); + if (this._state === 'streaming') this._syncStreamThrottle(); + }, + _syncInputCapture() { + const shouldCapture = this._sessionActive && !this._viewOnly && this._state === 'streaming'; + if (shouldCapture) this.input.start(); + else this.input.stop(); + }, + _getActiveStreamFps() { return this._savedActiveFps || 60; }, + _syncStreamThrottle() { + if (this._state !== 'streaming') return; + const wantActive = this._sessionActive; + if (this._streamThrottledActive === wantActive) return; + this._streamThrottledActive = wantActive; + if (wantActive) this._resumeActiveStream(); + else this._throttleBackgroundStream(); + }, + _throttleBackgroundStream() { + this.renderer.stopRenderLoop(); + this.video.setBackgroundMode(true); + this.setCustomFps(this._backgroundFps); + }, + _resumeActiveStream() { + this.video.setBackgroundMode(false); + this.renderer.startRenderLoop(); + this.setCustomFps(this._getActiveStreamFps()); + this._sendPeerMessage(this.proto.buildMisc('refreshVideo', true)); + }, + }; + return client; + } + + it('starts input only for the active streaming session', () => { + const active = makeClientStub(); + const background = makeClientStub(); + + active.setSessionActive(true); + background.setSessionActive(false); + + expect(active.input.start).toHaveBeenCalled(); + expect(background.input.stop).toHaveBeenCalled(); + expect(active._clipboardToLocalEnabled).toBe(true); + expect(background._clipboardToLocalEnabled).toBe(false); + }); + + it('throttles background stream to 1fps and resumes active stream at 60fps', () => { + const client = makeClientStub(); + client.setSessionActive(false); + + expect(client.renderer.stopRenderLoop).toHaveBeenCalled(); + expect(client.video.setBackgroundMode).toHaveBeenCalledWith(true); + expect(client.setCustomFps).toHaveBeenCalledWith(1); + + client.setSessionActive(true); + expect(client.video.setBackgroundMode).toHaveBeenCalledWith(false); + expect(client.renderer.startRenderLoop).toHaveBeenCalled(); + expect(client.setCustomFps).toHaveBeenCalledWith(60); + }); + + it('keeps input stopped in view-only mode even when active', () => { + const client = makeClientStub(); + client._viewOnly = true; + client.setSessionActive(true); + + expect(client.input.start).not.toHaveBeenCalled(); + expect(client.input.stop).toHaveBeenCalled(); + }); +}); + +describe('CDAPSession setSessionActive contract', () => { + function makeCdapStub() { + const client = { + _sessionActive: true, + _connected: true, + _clipboardToLocalEnabled: true, + _inputBound: false, + _bindInput() { + this._inputBound = true; + }, + _unbindInput() { + this._inputBound = false; + }, + setSessionActive(active) { + this._sessionActive = !!active; + this._clipboardToLocalEnabled = !!active; + this._syncInputCapture(); + }, + _syncInputCapture() { + if (this._sessionActive && this._connected) this._bindInput(); + else this._unbindInput(); + }, + }; + return client; + } + + it('binds input only for the active connected session', () => { + const active = makeCdapStub(); + const background = makeCdapStub(); + + active.setSessionActive(true); + background.setSessionActive(false); + + expect(active._inputBound).toBe(true); + expect(background._inputBound).toBe(false); + }); +}); diff --git a/web-nodejs/tests/remoteViewerPrefs.test.js b/web-nodejs/tests/remoteViewerPrefs.test.js new file mode 100644 index 00000000..bebcff24 --- /dev/null +++ b/web-nodejs/tests/remoteViewerPrefs.test.js @@ -0,0 +1,37 @@ +'use strict'; + +const { + BACKGROUND_FPS, + sanitizePrefs, + getActiveFpsForQuality, + getPresetForQuality, + loadRemoteViewerPrefs, + saveRemoteViewerPrefs, +} = require('../lib/remoteViewerPrefs'); + +describe('remoteViewerPrefs', () => { + it('defaults Best quality to 60fps preset', () => { + expect(getPresetForQuality('Best')).toBe('best'); + expect(getActiveFpsForQuality('Best')).toBe(60); + }); + + it('sanitizes unknown values', () => { + const clean = sanitizePrefs({ quality: 'Invalid', scale: 'fit', codec: 'Auto' }); + expect(clean.quality).toBe('Best'); + expect(clean.scale).toBe('fit'); + expect(clean.backgroundFps).toBe(BACKGROUND_FPS); + }); + + it('persists via in-memory storage', () => { + const storage = new Map(); + const store = { + getItem: (k) => storage.get(k) || null, + setItem: (k, v) => storage.set(k, v), + }; + saveRemoteViewerPrefs(7, { quality: 'Balanced', scale: '1:1' }, store); + const loaded = loadRemoteViewerPrefs(7, store); + expect(loaded.quality).toBe('Balanced'); + expect(loaded.scale).toBe('1:1'); + expect(getActiveFpsForQuality(loaded.quality)).toBe(30); + }); +}); diff --git a/web-nodejs/views/layouts/viewer.ejs b/web-nodejs/views/layouts/viewer.ejs index ed8a5562..6994567d 100644 --- a/web-nodejs/views/layouts/viewer.ejs +++ b/web-nodejs/views/layouts/viewer.ejs @@ -55,6 +55,8 @@ + + <% if (typeof pageScripts !== 'undefined' && pageScripts) { %>