mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
0e6d20ba5e
Send modifiers and nav keys as Map scancodes (not mixed Legacy controlKey), restore Auto/Windows hybrid for symbols, and add protobuf regression tests.
365 lines
12 KiB
JavaScript
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);
|
|
});
|
|
});
|