Files
Haven/test/screenRelayAuto.test.js
Bernardo Costa c3ade67336 Screen sharing: drop the native GStreamer path, add a bitrate setting, fix black tiles on GPU encoders (#5672)
The native GStreamer screen share path is gone: in a peer-to-peer mesh every
hardware and OS combination behaved differently, and keeping them in step cost
more than it saved. Screen sharing uses Chromium's own capture everywhere.

- Screen share bitrate is a setting: a stepper from 300 to 10,000 Kbps or
  unlimited, with press-and-hold repeat and typed entry. Changes apply live,
  including to a share going through the voice relay (debounced, so a hold
  becomes one update). The default stays 1080p at 30 fps with an 8,000 Kbps cap.
- Black screen share tiles on GPU encoders: the sharer asks for a fresh
  keyframe after negotiation, and the watchdog only re-adopts tracks a tile is
  not already showing.
- Relay screen publishing is serialized and tied to the share it was started
  for, so a stop, a new share or a relay restart can never publish a stale
  producer, and a failed first publish retries on its own.
2026-10-02 12:12:31 -04:00

147 lines
6.5 KiB
JavaScript

/**
* Automatic relay profile for screen share (#5426).
*
* The "Gentler screen share for relay connections" toggle was confirmed on
* two setups as the thing a relayed share needs. This applies it by itself,
* per viewer: once a peer connection settles, its selected candidate pair
* says whether the path runs through a TURN relay, and only that viewer's
* sender gets the gentler encoder settings.
*
* node --test test/screenRelayAuto.test.js
*/
'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(storage = {}) {
const context = vm.createContext({
module: { exports: {} },
navigator: { userAgent: '', platform: '', maxTouchPoints: 0 },
localStorage: {
getItem(k) { return Object.prototype.hasOwnProperty.call(storage, k) ? storage[k] : null; },
setItem(k, v) { storage[k] = String(v); },
removeItem(k) { delete storage[k]; }
},
console: { log() {}, warn() {}, error() {} },
RTCSessionDescription: function RTCSessionDescription(d) { return d; },
setTimeout,
clearTimeout,
Date
});
vm.runInContext(`${VOICE_SOURCE}\nmodule.exports = VoiceManager;`, context, { filename: 'voice.js' });
return context.module.exports;
}
// A peer connection whose stats say the selected pair is relayed or direct,
// with one screen-share video sender that records what it was told.
function fakeConnection(localType, remoteType, videoTrack) {
const stats = new Map([
['T1', { id: 'T1', type: 'transport', selectedCandidatePairId: 'P1' }],
['P1', { id: 'P1', type: 'candidate-pair', state: 'succeeded', nominated: true, localCandidateId: 'L1', remoteCandidateId: 'R1' }],
['L1', { id: 'L1', type: 'local-candidate', candidateType: localType }],
['R1', { id: 'R1', type: 'remote-candidate', candidateType: remoteType }]
]);
const sender = {
track: videoTrack,
applied: null,
getParameters() { return { encodings: [{}] }; },
setParameters(p) { this.applied = p; return Promise.resolve(); }
};
return {
sender,
getStats: async () => stats,
getSenders: () => [sender]
};
}
function makeSharer(VoiceManager, { local = 'host', remote = 'srflx' } = {}) {
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 1080;
voice.screenFrameRate = 30;
const connection = fakeConnection(local, remote, track);
voice.peers.set(2, { connection, username: 'viewer' });
return { voice, connection };
}
test('a relayed viewer gets the gentler profile on their sender only', async () => {
const VoiceManager = loadVoiceManager();
const { voice, connection } = makeSharer(VoiceManager, { local: 'relay' });
await voice._detectRelayPath(2, connection);
assert.equal(voice._relayPeers.has(2), true);
const p = connection.sender.applied;
assert.ok(p, 'sender parameters were re-applied once the relay was detected');
assert.equal(p.encodings[0].maxBitrate, 3_000_000, 'gentler 1080p ceiling');
assert.equal(p.encodings[0].maxFramerate, undefined, 'framerate left unpinned');
assert.equal(p.degradationPreference, 'balanced');
});
test('the remote end being a relay counts too', async () => {
const VoiceManager = loadVoiceManager();
const { voice, connection } = makeSharer(VoiceManager, { local: 'srflx', remote: 'relay' });
await voice._detectRelayPath(2, connection);
assert.equal(voice._relayPeers.has(2), true);
});
test('a direct viewer keeps the full-quality profile', async () => {
const VoiceManager = loadVoiceManager();
const { voice, connection } = makeSharer(VoiceManager);
await voice._detectRelayPath(2, connection);
assert.equal(voice._relayPeers.has(2), false);
// Nothing changed, so nothing was re-applied by detection; apply explicitly
// the way shareScreen does and check the full profile is what goes out.
voice._applyScreenBitrate(connection, 8_000_000, 2);
const p = connection.sender.applied;
assert.equal(p.encodings[0].maxBitrate, 8_000_000);
assert.equal(p.encodings[0].maxFramerate, 30);
assert.equal(p.degradationPreference, 'maintain-framerate');
});
test('switching the detection off in Debug leaves relayed viewers on the full profile', async () => {
const VoiceManager = loadVoiceManager({ haven_screen_relay_auto: '0' });
const { voice, connection } = makeSharer(VoiceManager, { local: 'relay' });
await voice._detectRelayPath(2, connection);
assert.equal(voice._relayPeers.has(2), true, 'the path is still recorded');
voice._applyScreenBitrate(connection, 8_000_000, 2);
assert.equal(connection.sender.applied.encodings[0].maxBitrate, 8_000_000);
assert.equal(connection.sender.applied.degradationPreference, 'maintain-framerate');
});
test('the manual toggle still applies the gentler profile to everyone', () => {
const VoiceManager = loadVoiceManager({ haven_screen_relay_profile: '1' });
const { voice, connection } = makeSharer(VoiceManager);
voice._applyScreenBitrate(connection, 8_000_000, 2);
assert.equal(connection.sender.applied.encodings[0].maxBitrate, 3_000_000);
assert.equal(connection.sender.applied.degradationPreference, 'balanced');
});
test('a peer torn down while stats were pending is not recorded', async () => {
const VoiceManager = loadVoiceManager();
const { voice, connection } = makeSharer(VoiceManager, { local: 'relay' });
const slow = { ...connection, getStats: () => new Promise((res) => setTimeout(() => res(connection.getStats()), 20)) };
voice.peers.set(2, { connection: slow, username: 'viewer' });
const pending = voice._detectRelayPath(2, slow);
voice.peers.delete(2);
await pending;
assert.equal(voice._relayPeers.has(2), false);
assert.equal(connection.sender.applied, null, 'nothing applied to a closed peer');
});
test('the userId is recovered from the connection when a caller does not pass it', async () => {
const VoiceManager = loadVoiceManager();
const { voice, connection } = makeSharer(VoiceManager, { local: 'relay' });
await voice._detectRelayPath(2, connection);
connection.sender.applied = null;
voice._applyScreenBitrate(connection, 8_000_000);
assert.equal(connection.sender.applied.encodings[0].maxBitrate, 3_000_000);
});