mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
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:
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) { %>
|
||||
|
||||
Reference in New Issue
Block a user