Files
BetterDesk/web-nodejs/tests/rdclient.sessionIsolation.test.js
UNITRONIX 0e6d20ba5e fix(rdclient): restore keyboard parity with RustDesk native Map path
Send modifiers and nav keys as Map scancodes (not mixed Legacy controlKey),
restore Auto/Windows hybrid for symbols, and add protobuf regression tests.
2026-07-05 00:53:44 +02:00

365 lines
12 KiB
JavaScript

'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,
};
if (!extraGlobals || extraGlobals.window === undefined) {
sandbox.window = sandbox;
}
sandbox.globalThis = sandbox.window;
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), sandbox, { filename });
return sandbox;
}
function loadBrowserScripts(relativePaths, extraGlobals) {
const sandbox = {
console,
window: {},
globalThis: {},
...extraGlobals,
};
if (!extraGlobals || extraGlobals.window === undefined) {
sandbox.window = sandbox;
}
sandbox.globalThis = sandbox.window;
for (const rel of relativePaths) {
const filename = path.join(__dirname, '..', rel);
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;
beforeAll(() => {
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 windowStub = {
addEventListener() {},
removeEventListener() {},
};
const sandbox = loadBrowserScripts([
'public/js/rdclient/keyboard-scancode.js',
'public/js/rdclient/keyboard-encoder.js',
'public/js/rdclient/input.js',
], {
document,
window: windowStub,
RDProtocol: {},
makeCanvas,
});
RDInput = sandbox.window.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();
sendA.mockClear();
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);
});
});