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.
This commit is contained in:
UNITRONIX
2026-06-14 10:09:59 +02:00
parent 59888c2ec0
commit a8f32a845f
12 changed files with 956 additions and 65 deletions
+98
View File
@@ -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,
};
+18
View File
@@ -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<string, { deviceId: string, state: string, client?: { setSessionActive?: Function } }>} 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 };
@@ -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);
+33 -15
View File
@@ -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;
+80 -7
View File
@@ -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);
+104 -10
View File
@@ -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; }
+27 -3
View File
@@ -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
+129 -30
View File
@@ -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 37 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();
+75
View File
@@ -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);
@@ -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);
});
});
@@ -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);
});
});
+2
View File
@@ -55,6 +55,8 @@
<script src="/js/rdclient/client.js?v=<%= cacheVersion %>"></script>
<!-- CDAP transport adapter (RDClient-compatible surface for OS-agent devices) -->
<script src="/js/rdclient/cdap-adapter.js?v=<%= cacheVersion %>"></script>
<script src="/js/lib/sessionMediaSync.js?v=<%= cacheVersion %>"></script>
<script src="/js/remoteViewerPrefs.js?v=<%= cacheVersion %>"></script>
<!-- Page-specific scripts -->
<% if (typeof pageScripts !== 'undefined' && pageScripts) { %>