mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 17:15:24 +00:00
Server-side signaling and codec negotiation for a native Desktop screen share path. Off unless the user opts in from Settings, and the toggle only appears inside Haven Desktop builds that carry the native helper. Browser and per-app audio capture are unchanged. Thanks to @bernardokcosta.
1366 lines
43 KiB
JavaScript
1366 lines
43 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
const vm = require('node:vm');
|
|
|
|
const VOICE_SOURCE = fs.readFileSync(
|
|
path.join(__dirname, '..', 'public/js/voice.js'),
|
|
'utf8'
|
|
);
|
|
|
|
function loadVoiceManager(globals = {}) {
|
|
const context = vm.createContext({
|
|
module: { exports: {} },
|
|
navigator: { userAgent: '', platform: '', maxTouchPoints: 0 },
|
|
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
|
console: { log() {}, warn() {}, error() {} },
|
|
setTimeout,
|
|
clearTimeout,
|
|
Date,
|
|
...globals,
|
|
});
|
|
vm.runInContext(`${VOICE_SOURCE}\nmodule.exports = VoiceManager;`, context, {
|
|
filename: 'voice.js',
|
|
});
|
|
return context.module.exports;
|
|
}
|
|
|
|
function completeNativeApi(overrides = {}) {
|
|
return {
|
|
getCapabilities: async () => ({ supported: true }),
|
|
start: async () => ({
|
|
started: true,
|
|
sessionId: 'native-session-1234',
|
|
codec: 'H264',
|
|
hasAudio: false,
|
|
}),
|
|
stop: async () => {},
|
|
addPeer: async () => {},
|
|
removePeer: async () => {},
|
|
setRemoteDescription: async () => {},
|
|
addIceCandidate: async () => {},
|
|
onSignal() {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('native screen start is transactional when the helper returns invalid state', async () => {
|
|
let stopCalls = 0;
|
|
const api = completeNativeApi({
|
|
start: async () => ({ started: true, sessionId: '../invalid' }),
|
|
stop: async () => { stopCalls++; },
|
|
});
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
screenResolution: 1080,
|
|
screenFrameRate: 30,
|
|
_screenBitrates: { 1080: 8_000_000, 0: 8_000_000 },
|
|
rtcConfig: { iceServers: [] },
|
|
peers: new Map(),
|
|
currentChannel: 'a1b2c3d4',
|
|
inVoice: true,
|
|
socket: { emit() {} },
|
|
});
|
|
|
|
assert.equal(await voice._tryStartNativeScreenShare(), null);
|
|
assert.equal(stopCalls, 1);
|
|
assert.notEqual(voice._nativeScreenSharing, true);
|
|
});
|
|
|
|
test('native picker cancellation does not fall through to another picker', async () => {
|
|
const api = completeNativeApi({
|
|
start: async () => ({ started: false, cancelled: true }),
|
|
});
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
screenResolution: 1080,
|
|
screenFrameRate: 30,
|
|
_screenBitrates: { 1080: 8_000_000, 0: 8_000_000 },
|
|
rtcConfig: { iceServers: [] },
|
|
peers: new Map(),
|
|
currentChannel: 'a1b2c3d4',
|
|
inVoice: true,
|
|
socket: { emit() {} },
|
|
});
|
|
|
|
assert.equal(await voice._tryStartNativeScreenShare(), false);
|
|
});
|
|
|
|
test('native transport falls back when a viewer does not support the protocol', async () => {
|
|
let stopCalls = 0;
|
|
const api = completeNativeApi({ stop: async () => { stopCalls++; } });
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
inVoice: true,
|
|
currentChannel: 'a1b2c3d4',
|
|
_voiceSessionGeneration: 3,
|
|
_screenStartOperation: 8,
|
|
screenResolution: 1080,
|
|
screenFrameRate: 30,
|
|
_screenBitrates: { 1080: 8_000_000, 0: 8_000_000 },
|
|
rtcConfig: { iceServers: [] },
|
|
peers: new Map([[7, {}]]),
|
|
socket: {
|
|
connected: true,
|
|
emit(event, payload, callback) {
|
|
if (event === 'screen-share-started') callback?.({ ok: false, error: 'incompatible_viewer' });
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.equal(await voice._tryStartNativeScreenShare(8, 'a1b2c3d4', 3), null);
|
|
assert.equal(stopCalls, 1);
|
|
assert.notEqual(voice._nativeScreenSharing, true);
|
|
});
|
|
|
|
test('voice bots do not participate in native screen codec negotiation', () => {
|
|
const VoiceManager = loadVoiceManager({ window: {} });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
peers: new Map([[7, {}], [8, {}]]),
|
|
_nativeScreenPeerCapabilities: new Map(),
|
|
_nativeScreenCodecs: () => ['H264', 'AV1'],
|
|
});
|
|
|
|
voice._rememberNativeScreenPeer({
|
|
id: 7,
|
|
isBot: true,
|
|
nativeScreenVersion: 0,
|
|
});
|
|
voice._rememberNativeScreenPeer({
|
|
id: 8,
|
|
nativeScreenVersion: 2,
|
|
nativeScreenCodecs: ['H264'],
|
|
});
|
|
|
|
assert.deepEqual(Array.from(voice._nativeScreenCodecIntersection()), ['H264']);
|
|
});
|
|
|
|
test('screen sharing cannot start while signaling is disconnected', async () => {
|
|
let startCalls = 0;
|
|
const api = completeNativeApi({ start: async () => { startCalls++; } });
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
inVoice: true,
|
|
isScreenSharing: false,
|
|
_screenStartInFlight: false,
|
|
socket: { connected: false },
|
|
});
|
|
|
|
assert.equal(await voice.shareScreen(), false);
|
|
assert.equal(startCalls, 0);
|
|
});
|
|
|
|
test('rebuilding a voice peer does not tear down its independent native screen peer', () => {
|
|
let closed = 0;
|
|
let nativeCloseCalls = 0;
|
|
let nativeRemoveCalls = 0;
|
|
const api = completeNativeApi({ removePeer: async () => { nativeRemoveCalls++; } });
|
|
const VoiceManager = loadVoiceManager({
|
|
window: { havenDesktop: { nativeScreen: api } },
|
|
document: { getElementById: () => null },
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
peers: new Map([[7, { connection: { close: () => { closed++; } } }]]),
|
|
gainNodes: new Map(),
|
|
screenGainNodes: new Map(),
|
|
_screenDelivered: new Set(),
|
|
_nativeScreenSharing: true,
|
|
_nativeScreenSessionId: 'native-session-1234',
|
|
_closeNativeScreenPeer: () => { nativeCloseCalls++; },
|
|
_stopAnalyser() {},
|
|
});
|
|
|
|
voice._removePeer(7);
|
|
|
|
assert.equal(closed, 1);
|
|
assert.equal(nativeCloseCalls, 0);
|
|
assert.equal(nativeRemoveCalls, 0);
|
|
});
|
|
|
|
test('native recovery ignores video receivers on the voice connection', () => {
|
|
const VoiceManager = loadVoiceManager({ window: {} });
|
|
const track = { kind: 'video', readyState: 'live', muted: false, id: 'camera-track' };
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
_nativeScreenAnnouncements: new Map([[7, 'native-session-1234']]),
|
|
_nativeScreenPeers: new Map(),
|
|
peers: new Map([[7, {
|
|
connection: { getReceivers: () => [{ track }] },
|
|
}]]),
|
|
screenSharers: new Set([7]),
|
|
});
|
|
|
|
assert.equal(voice._deliverScreenFromReceivers(7), false);
|
|
assert.equal(voice._screenStillLive(7), false);
|
|
});
|
|
|
|
test('native screen announcements keep voice video and audio on their own paths', async () => {
|
|
let connection;
|
|
let webcamStreams = 0;
|
|
let screenStreams = 0;
|
|
let voiceAudio = 0;
|
|
let screenAudio = 0;
|
|
class FakeMediaStream {
|
|
constructor(tracks = []) {
|
|
this.tracks = [...tracks];
|
|
this.id = `stream-${Math.random()}`;
|
|
}
|
|
addTrack(track) { this.tracks.push(track); }
|
|
getVideoTracks() { return this.tracks.filter(track => track.kind === 'video'); }
|
|
}
|
|
class FakePeerConnection {
|
|
constructor() {
|
|
connection = this;
|
|
this.signalingState = 'stable';
|
|
}
|
|
addEventListener() {}
|
|
}
|
|
const VoiceManager = loadVoiceManager({
|
|
window: {},
|
|
MediaStream: FakeMediaStream,
|
|
RTCPeerConnection: FakePeerConnection,
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
peers: new Map(),
|
|
rtcConfig: {},
|
|
localStream: null,
|
|
audioBitrate: 0,
|
|
screenStream: null,
|
|
isScreenSharing: false,
|
|
webcamStream: null,
|
|
isWebcamActive: false,
|
|
_nativeScreenAnnouncements: new Map([[7, 'native-session-1234']]),
|
|
screenSharers: new Set([7]),
|
|
webcamUsers: new Set(),
|
|
_screenDelivered: new Set(),
|
|
socket: { emit() {} },
|
|
onWebcamStream: () => { webcamStreams++; },
|
|
onScreenStream: () => { screenStreams++; },
|
|
_playAudio: () => { voiceAudio++; },
|
|
_playScreenAudio: () => { screenAudio++; },
|
|
});
|
|
|
|
await voice._createPeer(7, 'Viewer', false);
|
|
const videoTrack = {
|
|
kind: 'video', id: 'camera-track', readyState: 'live', getSettings: () => ({}),
|
|
};
|
|
connection.ontrack({
|
|
track: videoTrack,
|
|
streams: [new FakeMediaStream([videoTrack])],
|
|
});
|
|
const audioTrack = { kind: 'audio', id: 'voice-track', readyState: 'live' };
|
|
connection.ontrack({
|
|
track: audioTrack,
|
|
streams: [new FakeMediaStream([audioTrack])],
|
|
});
|
|
|
|
assert.equal(webcamStreams, 1);
|
|
assert.equal(screenStreams, 0);
|
|
assert.equal(voice._screenDelivered.has(7), false);
|
|
assert.equal(voiceAudio, 1);
|
|
assert.equal(screenAudio, 0);
|
|
});
|
|
|
|
test('stale browser track callbacks cannot overwrite a native share', async () => {
|
|
let connection;
|
|
let screenStreams = 0;
|
|
class FakeMediaStream {
|
|
constructor(tracks = []) { this.tracks = [...tracks]; }
|
|
addTrack(track) { this.tracks.push(track); }
|
|
getVideoTracks() { return this.tracks.filter(track => track.kind === 'video'); }
|
|
}
|
|
class FakePeerConnection {
|
|
constructor() {
|
|
connection = this;
|
|
this.signalingState = 'stable';
|
|
}
|
|
addEventListener() {}
|
|
}
|
|
const VoiceManager = loadVoiceManager({
|
|
window: {},
|
|
MediaStream: FakeMediaStream,
|
|
RTCPeerConnection: FakePeerConnection,
|
|
setTimeout: callback => { callback(); return 1; },
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
peers: new Map(),
|
|
rtcConfig: {},
|
|
localStream: null,
|
|
audioBitrate: 0,
|
|
screenStream: null,
|
|
isScreenSharing: false,
|
|
webcamStream: null,
|
|
isWebcamActive: false,
|
|
_nativeScreenAnnouncements: new Map(),
|
|
screenSharers: new Set([7]),
|
|
webcamUsers: new Set(),
|
|
_screenDelivered: new Set(),
|
|
socket: { emit() {} },
|
|
onScreenStream: () => { screenStreams++; },
|
|
});
|
|
|
|
await voice._createPeer(7, 'Viewer', false);
|
|
const track = {
|
|
kind: 'video', id: 'browser-screen', readyState: 'live', getSettings: () => ({}),
|
|
};
|
|
connection.ontrack({ track, streams: [new FakeMediaStream([track])] });
|
|
assert.equal(screenStreams, 1);
|
|
|
|
voice._nativeScreenAnnouncements.set(7, 'native-session-1234');
|
|
voice._screenDelivered.add(7);
|
|
track.onunmute();
|
|
track.onended();
|
|
|
|
assert.equal(screenStreams, 1);
|
|
assert.equal(voice._screenDelivered.has(7), true);
|
|
});
|
|
|
|
test('native receiver drains ICE that arrives while applying the offer', async () => {
|
|
let releaseRemoteDescription;
|
|
const addedCandidates = [];
|
|
class FakePeerConnection {
|
|
constructor() {
|
|
this.remoteDescription = null;
|
|
this.connectionState = 'new';
|
|
}
|
|
setRemoteDescription() {
|
|
return new Promise(resolve => {
|
|
releaseRemoteDescription = () => {
|
|
this.remoteDescription = { type: 'offer' };
|
|
resolve();
|
|
};
|
|
});
|
|
}
|
|
async createAnswer() { return { type: 'answer', sdp: 'v=0' }; }
|
|
async setLocalDescription() {}
|
|
async addIceCandidate(candidate) { addedCandidates.push(candidate); }
|
|
close() {}
|
|
}
|
|
const emitted = [];
|
|
const VoiceManager = loadVoiceManager({
|
|
window: {},
|
|
RTCPeerConnection: FakePeerConnection,
|
|
MediaStream: class {},
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
currentChannel: 'a1b2c3d4',
|
|
rtcConfig: {},
|
|
screenSharers: new Set([7]),
|
|
_screenDelivered: new Set(),
|
|
_nativeScreenPeers: new Map(),
|
|
_pendingNativeScreenCandidates: new Map(),
|
|
_nativeScreenAnnouncements: new Map([[7, 'native-session-1234']]),
|
|
socket: {
|
|
emit: (event, payload, callback) => {
|
|
emitted.push({ event, payload });
|
|
if (event === 'screen-share-started') callback?.({ ok: true, viewerIds: [7] });
|
|
},
|
|
},
|
|
});
|
|
|
|
const offerPromise = voice._handleNativeScreenOffer({
|
|
from: { id: 7 },
|
|
channelCode: 'a1b2c3d4',
|
|
sessionId: 'native-session-1234',
|
|
negotiationId: 'negotiation-1234',
|
|
offer: { type: 'offer', sdp: 'v=0' },
|
|
});
|
|
await Promise.resolve();
|
|
await voice._handleNativeScreenIceCandidate({
|
|
from: { id: 7 },
|
|
channelCode: 'a1b2c3d4',
|
|
sessionId: 'native-session-1234',
|
|
negotiationId: 'negotiation-1234',
|
|
candidate: { candidate: 'candidate:1' },
|
|
});
|
|
releaseRemoteDescription();
|
|
await offerPromise;
|
|
|
|
assert.deepEqual(addedCandidates, [{ candidate: 'candidate:1' }]);
|
|
assert.equal(emitted.at(-1).event, 'native-screen-answer');
|
|
});
|
|
|
|
test('fatal helper errors without a peer stop the native share', async () => {
|
|
let signalHandler;
|
|
let stopCalls = 0;
|
|
const api = completeNativeApi({
|
|
onSignal: handler => { signalHandler = handler; },
|
|
});
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
_nativeScreenSharing: true,
|
|
_nativeScreenSessionId: 'native-session-1234',
|
|
currentChannel: 'a1b2c3d4',
|
|
socket: { emit() {} },
|
|
stopScreenShare: async () => { stopCalls++; },
|
|
});
|
|
|
|
voice._setupNativeScreenBridge();
|
|
signalHandler({
|
|
type: 'error',
|
|
sessionId: 'native-session-1234',
|
|
peerId: null,
|
|
message: 'pipeline failed',
|
|
fatal: true,
|
|
});
|
|
await Promise.resolve();
|
|
|
|
assert.equal(stopCalls, 1);
|
|
});
|
|
|
|
test('sender queues native ICE until the browser answer is applied', async () => {
|
|
let releaseAnswer;
|
|
const calls = [];
|
|
const api = completeNativeApi({
|
|
setRemoteDescription: async () => {
|
|
calls.push('answer-start');
|
|
await new Promise(resolve => { releaseAnswer = resolve; });
|
|
calls.push('answer-done');
|
|
},
|
|
addIceCandidate: async ({ candidate }) => {
|
|
calls.push(candidate ? candidate.candidate : 'end-of-candidates');
|
|
},
|
|
});
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
_nativeScreenSharing: true,
|
|
_nativeScreenSessionId: 'native-session-1234',
|
|
_nativeScreenSenderStates: new Map([[
|
|
'7:native-session-1234:negotiation-1234',
|
|
{ ready: false, applying: null, candidates: [] },
|
|
]]),
|
|
currentChannel: 'a1b2c3d4',
|
|
});
|
|
|
|
const answer = voice._handleNativeScreenAnswer({
|
|
from: { id: 7 },
|
|
channelCode: 'a1b2c3d4',
|
|
sessionId: 'native-session-1234',
|
|
negotiationId: 'negotiation-1234',
|
|
answer: { type: 'answer', sdp: 'v=0' },
|
|
});
|
|
await Promise.resolve();
|
|
await voice._handleNativeScreenIceCandidate({
|
|
from: { id: 7 },
|
|
channelCode: 'a1b2c3d4',
|
|
sessionId: 'native-session-1234',
|
|
negotiationId: 'negotiation-1234',
|
|
candidate: { candidate: 'candidate:1' },
|
|
});
|
|
await voice._handleNativeScreenIceCandidate({
|
|
from: { id: 7 },
|
|
channelCode: 'a1b2c3d4',
|
|
sessionId: 'native-session-1234',
|
|
negotiationId: 'negotiation-1234',
|
|
candidate: null,
|
|
});
|
|
assert.deepEqual(calls, ['answer-start']);
|
|
releaseAnswer();
|
|
await answer;
|
|
assert.deepEqual(calls, ['answer-start', 'answer-done', 'candidate:1', 'end-of-candidates']);
|
|
});
|
|
|
|
test('native start is stopped without announcement after leaving during the picker', async () => {
|
|
let resolveStart;
|
|
let stopCalls = 0;
|
|
const emitted = [];
|
|
const api = completeNativeApi({
|
|
start: () => new Promise(resolve => { resolveStart = resolve; }),
|
|
stop: async () => { stopCalls++; },
|
|
});
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
inVoice: true,
|
|
currentChannel: 'a1b2c3d4',
|
|
_voiceSessionGeneration: 3,
|
|
_screenStartOperation: 8,
|
|
screenResolution: 1080,
|
|
screenFrameRate: 30,
|
|
_screenBitrates: { 1080: 8_000_000, 0: 8_000_000 },
|
|
rtcConfig: { iceServers: [] },
|
|
peers: new Map(),
|
|
socket: {
|
|
emit: (event, payload, callback) => {
|
|
emitted.push({ event, payload });
|
|
if (event === 'screen-share-started') callback?.({ ok: true, viewerIds: [7] });
|
|
},
|
|
},
|
|
});
|
|
|
|
const pending = voice._tryStartNativeScreenShare(8, 'a1b2c3d4', 3);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
voice.inVoice = false;
|
|
voice.currentChannel = null;
|
|
voice._screenStartOperation++;
|
|
resolveStart({ started: true, sessionId: 'native-session-1234' });
|
|
|
|
assert.equal(await pending, false);
|
|
assert.equal(stopCalls, 1);
|
|
assert.equal(emitted.length, 0);
|
|
assert.notEqual(voice._nativeScreenSharing, true);
|
|
});
|
|
|
|
test('a stale rejected start cannot stop or clear a newer native session', async () => {
|
|
let rejectStart;
|
|
const stops = [];
|
|
const api = completeNativeApi({
|
|
start: () => new Promise((_, reject) => { rejectStart = reject; }),
|
|
stop: async data => { stops.push(data || 'all'); },
|
|
});
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
inVoice: true,
|
|
currentChannel: 'a1b2c3d4',
|
|
_voiceSessionGeneration: 3,
|
|
_screenStartOperation: 8,
|
|
screenResolution: 1080,
|
|
screenFrameRate: 30,
|
|
_screenBitrates: { 1080: 8_000_000, 0: 8_000_000 },
|
|
rtcConfig: { iceServers: [] },
|
|
peers: new Map(),
|
|
socket: { connected: true, emit() {} },
|
|
});
|
|
|
|
const staleStart = voice._tryStartNativeScreenShare(8, 'a1b2c3d4', 3);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
voice._screenStartOperation = 9;
|
|
voice._nativeScreenSharing = true;
|
|
voice._nativeScreenSessionId = 'native-session-new1';
|
|
voice.isScreenSharing = true;
|
|
rejectStart(new Error('old start failed'));
|
|
|
|
assert.equal(await staleStart, null);
|
|
assert.deepEqual(stops, []);
|
|
assert.equal(voice._nativeScreenSharing, true);
|
|
assert.equal(voice._nativeScreenSessionId, 'native-session-new1');
|
|
assert.equal(voice.isScreenSharing, true);
|
|
});
|
|
|
|
test('stale native answers cannot replace the current viewer negotiation', async () => {
|
|
let applyCalls = 0;
|
|
const api = completeNativeApi({
|
|
setRemoteDescription: async () => { applyCalls++; },
|
|
});
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
_nativeScreenSharing: true,
|
|
_nativeScreenSessionId: 'native-session-1234',
|
|
_nativeScreenSenderStates: new Map([[
|
|
'7:native-session-1234:negotiation-current',
|
|
{ ready: false, applying: null, candidates: [] },
|
|
]]),
|
|
currentChannel: 'a1b2c3d4',
|
|
});
|
|
|
|
await voice._handleNativeScreenAnswer({
|
|
from: { id: 7 },
|
|
channelCode: 'a1b2c3d4',
|
|
sessionId: 'native-session-1234',
|
|
negotiationId: 'negotiation-stale',
|
|
answer: { type: 'answer', sdp: 'v=0' },
|
|
});
|
|
|
|
assert.equal(applyCalls, 0);
|
|
});
|
|
|
|
test('an empty screen snapshot removes stale local sharers', () => {
|
|
const handlers = new Map();
|
|
const removed = [];
|
|
const VoiceManager = loadVoiceManager({
|
|
window: {},
|
|
document: { querySelectorAll: () => [] },
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
socket: { on: (event, handler) => handlers.set(event, handler) },
|
|
currentChannel: 'a1b2c3d4',
|
|
localUserId: 1,
|
|
screenSharers: new Set([7]),
|
|
webcamUsers: new Set(),
|
|
_screenDelivered: new Set([7]),
|
|
_nativeScreenPeers: new Map(),
|
|
_pendingNativeScreenCandidates: new Map(),
|
|
_nativeScreenAnnouncements: new Map([[7, 'native-session-1234']]),
|
|
_screenWatchdogTimers: new Map(),
|
|
onScreenStream: (userId, stream) => removed.push({ userId, stream }),
|
|
});
|
|
voice._setupSocketListeners();
|
|
|
|
handlers.get('active-screen-sharers')({ channelCode: 'a1b2c3d4', sharers: [] });
|
|
|
|
assert.equal(voice.screenSharers.size, 0);
|
|
assert.equal(voice._nativeScreenAnnouncements.size, 0);
|
|
assert.deepEqual(removed, [{ userId: 7, stream: null }]);
|
|
});
|
|
|
|
test('an active screen snapshot preserves the authoritative no-audio state', () => {
|
|
const handlers = new Map();
|
|
const noAudio = [];
|
|
const VoiceManager = loadVoiceManager({
|
|
window: {},
|
|
document: { querySelectorAll: () => [] },
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
socket: { on: (event, handler) => handlers.set(event, handler) },
|
|
currentChannel: 'a1b2c3d4',
|
|
localUserId: 1,
|
|
screenSharers: new Set(),
|
|
webcamUsers: new Set(),
|
|
_screenDelivered: new Set(),
|
|
_nativeScreenPeers: new Map(),
|
|
_pendingNativeScreenCandidates: new Map(),
|
|
_nativeScreenAnnouncements: new Map(),
|
|
_screenWatchdogTimers: new Map(),
|
|
_watchForScreenStream() {},
|
|
onScreenNoAudio: userId => noAudio.push(userId),
|
|
});
|
|
voice._setupSocketListeners();
|
|
|
|
handlers.get('active-screen-sharers')({
|
|
channelCode: 'a1b2c3d4',
|
|
sharers: [{
|
|
id: 7,
|
|
username: 'Remote user',
|
|
transport: 'native',
|
|
sessionId: 'native-session-1234',
|
|
codec: 'H264',
|
|
hasAudio: false,
|
|
}],
|
|
});
|
|
|
|
assert.deepEqual(noAudio, [7]);
|
|
});
|
|
|
|
test('an empty recovery snapshot preserves an active local share', () => {
|
|
const handlers = new Map();
|
|
const removed = [];
|
|
const VoiceManager = loadVoiceManager({
|
|
window: {},
|
|
document: { querySelectorAll: () => [] },
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
socket: { on: (event, handler) => handlers.set(event, handler) },
|
|
currentChannel: 'a1b2c3d4',
|
|
localUserId: 1,
|
|
isScreenSharing: true,
|
|
screenSharers: new Set([1]),
|
|
webcamUsers: new Set(),
|
|
_screenDelivered: new Set([1]),
|
|
_nativeScreenPeers: new Map(),
|
|
_pendingNativeScreenCandidates: new Map(),
|
|
_nativeScreenAnnouncements: new Map([[1, 'native-session-1234']]),
|
|
_screenWatchdogTimers: new Map(),
|
|
onScreenStream: (userId, stream) => removed.push({ userId, stream }),
|
|
});
|
|
voice._setupSocketListeners();
|
|
|
|
handlers.get('active-screen-sharers')({ channelCode: 'a1b2c3d4', sharers: [] });
|
|
|
|
assert.equal(voice.screenSharers.has(1), true);
|
|
assert.equal(removed.length, 0);
|
|
});
|
|
|
|
test('a stale server snapshot cannot restore a stopped local share', () => {
|
|
const handlers = new Map();
|
|
const VoiceManager = loadVoiceManager({
|
|
window: {},
|
|
document: { querySelectorAll: () => [] },
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
socket: { on: (event, handler) => handlers.set(event, handler) },
|
|
currentChannel: 'a1b2c3d4',
|
|
localUserId: 1,
|
|
isScreenSharing: false,
|
|
screenSharers: new Set(),
|
|
webcamUsers: new Set(),
|
|
_screenDelivered: new Set(),
|
|
_nativeScreenPeers: new Map(),
|
|
_pendingNativeScreenCandidates: new Map(),
|
|
_nativeScreenAnnouncements: new Map(),
|
|
_screenWatchdogTimers: new Map(),
|
|
});
|
|
voice._setupSocketListeners();
|
|
|
|
handlers.get('active-screen-sharers')({
|
|
channelCode: 'a1b2c3d4',
|
|
sharers: [{
|
|
id: 1,
|
|
username: 'Local user',
|
|
transport: 'native',
|
|
sessionId: 'native-session-1234',
|
|
codec: 'H264',
|
|
}],
|
|
});
|
|
|
|
assert.equal(voice.screenSharers.has(1), false);
|
|
assert.equal(voice._nativeScreenAnnouncements.has(1), false);
|
|
});
|
|
|
|
test('browser screen sharing is reannounced after voice rejoin', async () => {
|
|
const emitted = [];
|
|
const VoiceManager = loadVoiceManager({ window: {} });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
isScreenSharing: true,
|
|
_nativeScreenSharing: false,
|
|
currentChannel: 'a1b2c3d4',
|
|
screenStream: { getAudioTracks: () => [{}] },
|
|
socket: {
|
|
emit: (event, payload, callback) => {
|
|
emitted.push({ event, payload });
|
|
callback?.({ ok: true, viewerIds: [] });
|
|
},
|
|
},
|
|
});
|
|
|
|
await voice._reannounceScreenShare([]);
|
|
|
|
assert.deepEqual(JSON.parse(JSON.stringify(emitted)), [{
|
|
event: 'screen-share-started',
|
|
payload: { code: 'a1b2c3d4', hasAudio: true, transport: 'browser' },
|
|
}]);
|
|
});
|
|
|
|
test('screen reannouncement stops local sharing when the server rejects it', async () => {
|
|
let stopCalls = 0;
|
|
const VoiceManager = loadVoiceManager({ window: {} });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
isScreenSharing: true,
|
|
_nativeScreenSharing: false,
|
|
currentChannel: 'a1b2c3d4',
|
|
screenStream: { getAudioTracks: () => [] },
|
|
socket: {
|
|
emit(event, payload, callback) {
|
|
callback?.({ ok: false, error: 'rate_limited' });
|
|
},
|
|
},
|
|
stopScreenShare: async () => { stopCalls++; },
|
|
});
|
|
|
|
assert.equal(await voice._reannounceScreenShare([]), false);
|
|
assert.equal(stopCalls, 1);
|
|
});
|
|
|
|
test('unknown native ICE negotiations are bounded per sharer session', async () => {
|
|
const VoiceManager = loadVoiceManager({ window: {} });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
_nativeScreenSharing: false,
|
|
currentChannel: 'a1b2c3d4',
|
|
_nativeScreenAnnouncements: new Map([[7, 'native-session-1234']]),
|
|
_nativeScreenPeers: new Map(),
|
|
_pendingNativeScreenCandidates: new Map(),
|
|
});
|
|
|
|
for (let index = 0; index < 10; index++) {
|
|
await voice._handleNativeScreenIceCandidate({
|
|
from: { id: 7 },
|
|
channelCode: 'a1b2c3d4',
|
|
sessionId: 'native-session-1234',
|
|
negotiationId: `negotiation-${index}`,
|
|
candidate: { candidate: `candidate:${index}` },
|
|
});
|
|
}
|
|
|
|
assert.equal(voice._pendingNativeScreenCandidates.size, 4);
|
|
});
|
|
|
|
test('native peer recovery clears stale UI and rearms retries', () => {
|
|
const removed = [];
|
|
let requested = 0;
|
|
let watched = 0;
|
|
const VoiceManager = loadVoiceManager({ window: {} });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
screenSharers: new Set([7]),
|
|
_screenDelivered: new Set([7]),
|
|
_nativeScreenPeers: new Map(),
|
|
_pendingNativeScreenCandidates: new Map(),
|
|
_screenWatchdogTimers: new Map(),
|
|
onScreenStream: (userId, stream) => removed.push({ userId, stream }),
|
|
requestScreenStream: () => { requested++; },
|
|
_watchForScreenStream: () => { watched++; },
|
|
});
|
|
|
|
voice._recoverNativeScreenPeer(7);
|
|
|
|
assert.equal(voice._screenDelivered.has(7), false);
|
|
assert.deepEqual(removed, [{ userId: 7, stream: null }]);
|
|
assert.equal(requested, 1);
|
|
assert.equal(watched, 1);
|
|
});
|
|
|
|
test('a no-op voice rejoin does not churn native screen peers', async () => {
|
|
const handlers = new Map();
|
|
let reannounced = 0;
|
|
const VoiceManager = loadVoiceManager({
|
|
window: {},
|
|
document: { querySelectorAll: () => [] },
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
socket: { on: (event, handler) => handlers.set(event, handler) },
|
|
peers: new Map(),
|
|
inVoice: true,
|
|
currentChannel: 'a1b2c3d4',
|
|
_voiceSessionGeneration: 2,
|
|
_reannounceScreenShare: async () => { reannounced++; },
|
|
_rearmScreenWatchdogs() {},
|
|
});
|
|
voice._setupSocketListeners();
|
|
|
|
await handlers.get('voice-existing-users')({
|
|
channelCode: 'a1b2c3d4',
|
|
users: [],
|
|
rejoin: true,
|
|
skipRenegotiate: true,
|
|
});
|
|
|
|
assert.equal(reannounced, 0);
|
|
});
|
|
|
|
test('voice rejoin flushes a screen stop queued while disconnected', async () => {
|
|
const handlers = new Map();
|
|
const emitted = [];
|
|
const VoiceManager = loadVoiceManager({
|
|
window: {},
|
|
document: { querySelectorAll: () => [] },
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
socket: {
|
|
connected: true,
|
|
on: (event, handler) => handlers.set(event, handler),
|
|
emit: (event, payload, callback) => {
|
|
emitted.push({ event, payload });
|
|
callback?.({ ok: true });
|
|
},
|
|
},
|
|
peers: new Map(),
|
|
inVoice: true,
|
|
currentChannel: 'a1b2c3d4',
|
|
_voiceSessionGeneration: 2,
|
|
_pendingScreenStop: { code: 'a1b2c3d4', sessionId: 'native-session-1234' },
|
|
_rearmScreenWatchdogs() {},
|
|
});
|
|
voice._setupSocketListeners();
|
|
|
|
await handlers.get('voice-existing-users')({
|
|
channelCode: 'a1b2c3d4',
|
|
users: [],
|
|
rejoin: true,
|
|
skipRenegotiate: true,
|
|
});
|
|
|
|
assert.deepEqual(JSON.parse(JSON.stringify(emitted[0])), {
|
|
event: 'screen-share-stopped',
|
|
payload: { code: 'a1b2c3d4', sessionId: 'native-session-1234' },
|
|
});
|
|
assert.equal(voice._pendingScreenStop, null);
|
|
});
|
|
|
|
test('native start revalidates voice ownership after attaching initial peers', async () => {
|
|
let resolvePeer;
|
|
const stops = [];
|
|
const emitted = [];
|
|
const api = completeNativeApi({
|
|
addPeer: () => new Promise(resolve => { resolvePeer = resolve; }),
|
|
stop: async data => { stops.push(data); },
|
|
});
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
inVoice: true,
|
|
currentChannel: 'a1b2c3d4',
|
|
_voiceSessionGeneration: 3,
|
|
_screenStartOperation: 8,
|
|
screenResolution: 1080,
|
|
screenFrameRate: 30,
|
|
_screenBitrates: { 1080: 8_000_000, 0: 8_000_000 },
|
|
rtcConfig: { iceServers: [] },
|
|
peers: new Map([[7, {}]]),
|
|
_nativeScreenSenderStates: new Map(),
|
|
socket: {
|
|
emit: (event, payload, callback) => {
|
|
emitted.push({ event, payload });
|
|
if (event === 'screen-share-started') callback?.({ ok: true, viewerIds: [7] });
|
|
},
|
|
},
|
|
});
|
|
|
|
const pending = voice._tryStartNativeScreenShare(8, 'a1b2c3d4', 3);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
voice.inVoice = false;
|
|
voice.currentChannel = null;
|
|
voice._screenStartOperation++;
|
|
resolvePeer();
|
|
|
|
assert.equal(await pending, false);
|
|
assert.deepEqual(JSON.parse(JSON.stringify(stops)), [{ sessionId: 'native-session-1234' }]);
|
|
assert.equal(emitted.at(-1).event, 'screen-share-stopped');
|
|
assert.equal(voice.isScreenSharing, false);
|
|
});
|
|
|
|
test('native start cleanup follows a channel code rotation', async () => {
|
|
let resolvePeer;
|
|
const emitted = [];
|
|
const api = completeNativeApi({
|
|
addPeer: () => new Promise(resolve => { resolvePeer = resolve; }),
|
|
});
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
inVoice: true,
|
|
currentChannel: 'a1b2c3d4',
|
|
_voiceSessionGeneration: 3,
|
|
_screenStartOperation: 8,
|
|
screenResolution: 1080,
|
|
screenFrameRate: 30,
|
|
_screenBitrates: { 1080: 8_000_000, 0: 8_000_000 },
|
|
rtcConfig: { iceServers: [] },
|
|
peers: new Map([[7, {}]]),
|
|
_nativeScreenSenderStates: new Map(),
|
|
socket: {
|
|
connected: true,
|
|
emit: (event, payload, callback) => {
|
|
emitted.push({ event, payload });
|
|
if (event === 'screen-share-started') callback?.({ ok: true, viewerIds: [7] });
|
|
},
|
|
},
|
|
});
|
|
|
|
const pending = voice._tryStartNativeScreenShare(8, 'a1b2c3d4', 3);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
voice.currentChannel = 'd4c3b2a1';
|
|
resolvePeer();
|
|
|
|
assert.equal(await pending, false);
|
|
assert.deepEqual(JSON.parse(JSON.stringify(emitted.at(-1))), {
|
|
event: 'screen-share-stopped',
|
|
payload: { code: 'd4c3b2a1', sessionId: 'native-session-1234' },
|
|
});
|
|
});
|
|
|
|
test('an active native share is reannounced after channel rotation', async () => {
|
|
const emitted = [];
|
|
const VoiceManager = loadVoiceManager({ window: {} });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
peers: new Map(),
|
|
inVoice: true,
|
|
currentChannel: 'd4c3b2a1',
|
|
_voiceSessionGeneration: 3,
|
|
isScreenSharing: true,
|
|
_nativeScreenSharing: true,
|
|
_nativeScreenSessionId: 'native-session-1234',
|
|
socket: {
|
|
emit: (event, payload, callback) => {
|
|
emitted.push({ event, payload });
|
|
if (event === 'screen-share-started') callback?.({ ok: true, viewerIds: [] });
|
|
},
|
|
},
|
|
_healPeerConnections() {},
|
|
});
|
|
|
|
await voice._healPeerConnectionsAfterChannelRotation('a1b2c3d4');
|
|
await Promise.resolve();
|
|
|
|
assert.deepEqual(JSON.parse(JSON.stringify(emitted[0])), {
|
|
event: 'screen-share-started',
|
|
payload: {
|
|
code: 'd4c3b2a1',
|
|
hasAudio: false,
|
|
transport: 'native',
|
|
sessionId: 'native-session-1234',
|
|
codec: 'H264',
|
|
},
|
|
});
|
|
});
|
|
|
|
test('native stop signals the server even when the helper hangs', async () => {
|
|
const emitted = [];
|
|
let screenCleanupCalls = 0;
|
|
const api = completeNativeApi({ stop: () => new Promise(() => {}) });
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
localUserId: 1,
|
|
currentChannel: 'a1b2c3d4',
|
|
_voiceSessionGeneration: 3,
|
|
_nativeScreenOperationTimeoutMs: 5,
|
|
isScreenSharing: true,
|
|
_nativeScreenSharing: true,
|
|
_nativeScreenSessionId: 'native-session-1234',
|
|
_nativeScreenSenderStates: new Map(),
|
|
screenSharers: new Set([1]),
|
|
_nativeScreenAnnouncements: new Map([[1, 'native-session-1234']]),
|
|
socket: {
|
|
connected: true,
|
|
emit: (event, payload) => emitted.push({ event, payload }),
|
|
},
|
|
onScreenStream: () => { screenCleanupCalls++; },
|
|
});
|
|
|
|
const stopping = voice.stopScreenShare();
|
|
assert.deepEqual(JSON.parse(JSON.stringify(emitted[0])), {
|
|
event: 'screen-share-stopped',
|
|
payload: { code: 'a1b2c3d4', sessionId: 'native-session-1234' },
|
|
});
|
|
assert.equal(screenCleanupCalls, 1);
|
|
await stopping;
|
|
assert.equal(voice.isScreenSharing, false);
|
|
assert.equal(screenCleanupCalls, 1);
|
|
});
|
|
|
|
test('native stop is queued until signaling reconnects', async () => {
|
|
const emitted = [];
|
|
const api = completeNativeApi();
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const socket = {
|
|
connected: false,
|
|
emit: (event, payload) => emitted.push({ event, payload }),
|
|
};
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
localUserId: 1,
|
|
currentChannel: 'a1b2c3d4',
|
|
_voiceSessionGeneration: 3,
|
|
isScreenSharing: true,
|
|
_nativeScreenSharing: true,
|
|
_nativeScreenSessionId: 'native-session-1234',
|
|
_nativeScreenSenderStates: new Map(),
|
|
screenSharers: new Set([1]),
|
|
_nativeScreenAnnouncements: new Map([[1, 'native-session-1234']]),
|
|
socket,
|
|
});
|
|
|
|
await voice.stopScreenShare();
|
|
assert.equal(emitted.length, 0);
|
|
assert.deepEqual(
|
|
JSON.parse(JSON.stringify(voice._pendingScreenStop)),
|
|
{ code: 'a1b2c3d4', sessionId: 'native-session-1234' }
|
|
);
|
|
|
|
socket.connected = true;
|
|
assert.equal(voice._flushPendingScreenStop('a1b2c3d4'), true);
|
|
assert.deepEqual(JSON.parse(JSON.stringify(emitted[0])), {
|
|
event: 'screen-share-stopped',
|
|
payload: { code: 'a1b2c3d4', sessionId: 'native-session-1234' },
|
|
});
|
|
});
|
|
|
|
test('native start uses the gentler relay bitrate profile', async () => {
|
|
let startOptions;
|
|
const api = completeNativeApi({
|
|
start: async options => {
|
|
startOptions = options;
|
|
return {
|
|
started: true,
|
|
sessionId: 'native-session-1234',
|
|
codec: 'H264',
|
|
hasAudio: false,
|
|
};
|
|
},
|
|
});
|
|
const VoiceManager = loadVoiceManager({
|
|
window: { havenDesktop: { nativeScreen: api } },
|
|
localStorage: {
|
|
getItem: key => key === 'haven_screen_relay_profile' ? '1' : null,
|
|
setItem() {},
|
|
removeItem() {},
|
|
},
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
inVoice: true,
|
|
currentChannel: 'a1b2c3d4',
|
|
_voiceSessionGeneration: 3,
|
|
_screenStartOperation: 8,
|
|
screenResolution: 1080,
|
|
screenFrameRate: 30,
|
|
_screenBitrates: { 1080: 8_000_000, 0: 8_000_000 },
|
|
rtcConfig: { iceServers: [] },
|
|
peers: new Map(),
|
|
_nativeScreenSenderStates: new Map(),
|
|
socket: {
|
|
connected: true,
|
|
emit(event, payload, callback) {
|
|
if (event === 'screen-share-started') callback?.({ ok: true, viewerIds: [] });
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.equal(await voice._tryStartNativeScreenShare(8, 'a1b2c3d4', 3), true);
|
|
assert.equal(startOptions.bitrate, 3_000_000);
|
|
});
|
|
|
|
test('fatal helper failure invalidates a start waiting on initial peers', async () => {
|
|
let resolvePeer;
|
|
const api = completeNativeApi({
|
|
addPeer: () => new Promise(resolve => { resolvePeer = resolve; }),
|
|
});
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
inVoice: true,
|
|
currentChannel: 'a1b2c3d4',
|
|
localUserId: 1,
|
|
_voiceSessionGeneration: 3,
|
|
_screenStartOperation: 8,
|
|
screenResolution: 1080,
|
|
screenFrameRate: 30,
|
|
_screenBitrates: { 1080: 8_000_000, 0: 8_000_000 },
|
|
rtcConfig: { iceServers: [] },
|
|
peers: new Map([[7, {}]]),
|
|
screenSharers: new Set(),
|
|
_nativeScreenAnnouncements: new Map(),
|
|
_nativeScreenSenderStates: new Map(),
|
|
socket: {
|
|
emit(event, payload, callback) {
|
|
if (event === 'screen-share-started') callback?.({ ok: true, viewerIds: [7] });
|
|
},
|
|
},
|
|
});
|
|
|
|
const pending = voice._tryStartNativeScreenShare(8, 'a1b2c3d4', 3);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
voice._handleNativeScreenFailure('pipeline failed');
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
resolvePeer();
|
|
|
|
assert.equal(await pending, false);
|
|
assert.equal(voice.isScreenSharing, false);
|
|
});
|
|
|
|
test('an ended native track uses the retrying recovery path', async () => {
|
|
let connection;
|
|
class FakePeerConnection {
|
|
constructor() {
|
|
connection = this;
|
|
this.connectionState = 'new';
|
|
this.remoteDescription = null;
|
|
}
|
|
async setRemoteDescription(description) { this.remoteDescription = description; }
|
|
async createAnswer() { return { type: 'answer', sdp: 'v=0' }; }
|
|
async setLocalDescription() {}
|
|
close() {}
|
|
}
|
|
const VoiceManager = loadVoiceManager({
|
|
window: {},
|
|
RTCPeerConnection: FakePeerConnection,
|
|
MediaStream: class {},
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
let recoveries = 0;
|
|
Object.assign(voice, {
|
|
currentChannel: 'a1b2c3d4',
|
|
rtcConfig: {},
|
|
screenSharers: new Set([7]),
|
|
_screenDelivered: new Set(),
|
|
_nativeScreenPeers: new Map(),
|
|
_pendingNativeScreenCandidates: new Map(),
|
|
_nativeScreenAnnouncements: new Map([[7, 'native-session-1234']]),
|
|
_recoverNativeScreenPeer: () => { recoveries++; },
|
|
socket: { emit() {} },
|
|
});
|
|
await voice._handleNativeScreenOffer({
|
|
from: { id: 7 },
|
|
channelCode: 'a1b2c3d4',
|
|
sessionId: 'native-session-1234',
|
|
negotiationId: 'negotiation-1234',
|
|
offer: { type: 'offer', sdp: 'v=0' },
|
|
});
|
|
const track = { kind: 'video' };
|
|
connection.ontrack({ track, streams: [{}] });
|
|
track.onended();
|
|
|
|
assert.equal(recoveries, 1);
|
|
});
|
|
|
|
test('stopping a native share removes a local snapshot badge', async () => {
|
|
const api = completeNativeApi();
|
|
const VoiceManager = loadVoiceManager({ window: { havenDesktop: { nativeScreen: api } } });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
localUserId: 1,
|
|
currentChannel: 'a1b2c3d4',
|
|
isScreenSharing: true,
|
|
_nativeScreenSharing: true,
|
|
_nativeScreenSessionId: 'native-session-1234',
|
|
_nativeScreenSenderStates: new Map(),
|
|
screenSharers: new Set([1]),
|
|
_nativeScreenAnnouncements: new Map([[1, 'native-session-1234']]),
|
|
socket: { emit() {} },
|
|
});
|
|
|
|
await voice.stopScreenShare();
|
|
|
|
assert.equal(voice.screenSharers.has(1), false);
|
|
assert.equal(voice._nativeScreenAnnouncements.has(1), false);
|
|
});
|
|
|
|
test('browser fallback revalidates the voice operation after renegotiation', async () => {
|
|
let resolveRenegotiation;
|
|
let renegotiationCalls = 0;
|
|
let stopCalls = 0;
|
|
const emitted = [];
|
|
const videoTrack = { kind: 'video', readyState: 'live', stop() { stopCalls++; } };
|
|
const stream = {
|
|
getTracks: () => [videoTrack],
|
|
getVideoTracks: () => [videoTrack],
|
|
getAudioTracks: () => [],
|
|
};
|
|
const VoiceManager = loadVoiceManager({
|
|
window: {},
|
|
navigator: {
|
|
userAgent: '', platform: '', maxTouchPoints: 0,
|
|
mediaDevices: { getDisplayMedia: async () => stream },
|
|
},
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
inVoice: true,
|
|
currentChannel: 'a1b2c3d4',
|
|
_voiceSessionGeneration: 3,
|
|
_screenStartOperation: 8,
|
|
_screenStartInFlight: false,
|
|
isScreenSharing: false,
|
|
screenResolution: 1080,
|
|
screenFrameRate: 30,
|
|
_screenBitrates: { 1080: 8_000_000, 0: 8_000_000 },
|
|
rtcConfig: { iceServers: [] },
|
|
peers: new Map([[7, {
|
|
connection: { addTrack() {}, getSenders: () => [], removeTrack() {} },
|
|
}]]),
|
|
localUserId: 1,
|
|
screenSharers: new Set([1]),
|
|
_nativeScreenAnnouncements: new Map(),
|
|
socket: {
|
|
connected: true,
|
|
emit: (event, payload, callback) => {
|
|
emitted.push({ event, payload });
|
|
if (event === 'screen-share-started') callback?.({ ok: true, viewerIds: [7] });
|
|
},
|
|
},
|
|
_applyScreenBitrate() {},
|
|
_renegotiate: () => {
|
|
if (renegotiationCalls++ > 0) return Promise.resolve();
|
|
return new Promise(resolve => { resolveRenegotiation = resolve; });
|
|
},
|
|
});
|
|
|
|
const pending = voice.shareScreen();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
voice.currentChannel = 'd4c3b2a1';
|
|
voice._screenStartOperation++;
|
|
resolveRenegotiation();
|
|
|
|
assert.equal(await pending, false);
|
|
assert.equal(stopCalls, 1);
|
|
assert.equal(voice.isScreenSharing, false);
|
|
assert.equal(voice.screenStream, null);
|
|
assert.deepEqual(JSON.parse(JSON.stringify(emitted.at(-1))), {
|
|
event: 'screen-share-stopped',
|
|
payload: { code: 'd4c3b2a1' },
|
|
});
|
|
});
|
|
|
|
test('browser capture is rolled back when the server rejects screen start', async () => {
|
|
let stopCalls = 0;
|
|
const videoTrack = { kind: 'video', readyState: 'live', stop() { stopCalls++; } };
|
|
const stream = {
|
|
getTracks: () => [videoTrack],
|
|
getVideoTracks: () => [videoTrack],
|
|
getAudioTracks: () => [],
|
|
};
|
|
const VoiceManager = loadVoiceManager({
|
|
window: {},
|
|
navigator: {
|
|
userAgent: '', platform: '', maxTouchPoints: 0,
|
|
mediaDevices: { getDisplayMedia: async () => stream },
|
|
},
|
|
});
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
inVoice: true,
|
|
currentChannel: 'a1b2c3d4',
|
|
_voiceSessionGeneration: 3,
|
|
_screenStartOperation: 8,
|
|
_screenStartInFlight: false,
|
|
isScreenSharing: false,
|
|
screenResolution: 1080,
|
|
screenFrameRate: 30,
|
|
_screenBitrates: { 1080: 8_000_000, 0: 8_000_000 },
|
|
peers: new Map(),
|
|
socket: {
|
|
connected: true,
|
|
emit(event, payload, callback) {
|
|
if (event === 'screen-share-started') callback?.({ ok: false, error: 'streams_disabled' });
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.equal(await voice.shareScreen(), false);
|
|
assert.equal(stopCalls, 1);
|
|
assert.equal(voice.isScreenSharing, false);
|
|
assert.equal(voice.screenStream, null);
|
|
});
|
|
|
|
test('an incompatible late viewer stops an active native share', async () => {
|
|
const handlers = new Map();
|
|
let stopCalls = 0;
|
|
let warnings = 0;
|
|
const VoiceManager = loadVoiceManager({ window: {} });
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
Object.assign(voice, {
|
|
socket: { on: (event, handler) => handlers.set(event, handler) },
|
|
currentChannel: 'a1b2c3d4',
|
|
_nativeScreenSharing: true,
|
|
_nativeScreenSessionId: 'native-session-1234',
|
|
onScreenShareWarning: () => { warnings++; },
|
|
stopScreenShare: async () => { stopCalls++; },
|
|
});
|
|
voice._setupSocketListeners();
|
|
|
|
handlers.get('native-screen-incompatible-peer')({
|
|
channelCode: 'a1b2c3d4',
|
|
userId: 7,
|
|
sessionId: 'native-session-old1',
|
|
});
|
|
await Promise.resolve();
|
|
|
|
assert.equal(stopCalls, 0);
|
|
assert.equal(warnings, 0);
|
|
|
|
handlers.get('native-screen-incompatible-peer')({
|
|
channelCode: 'a1b2c3d4',
|
|
userId: 7,
|
|
sessionId: 'native-session-1234',
|
|
});
|
|
await Promise.resolve();
|
|
|
|
assert.equal(stopCalls, 1);
|
|
assert.equal(warnings, 1);
|
|
});
|