@stream-io/video-client 1.57.0 → 1.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/e2ee-worker.browser.es.js +1342 -0
- package/dist/e2ee-worker.browser.es.js.map +1 -0
- package/dist/index.browser.es.js +831 -55
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +875 -54
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.es.js +864 -55
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +52 -6
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/devices/CameraManager.d.ts +6 -0
- package/dist/src/devices/DeviceManager.d.ts +1 -0
- package/dist/src/devices/MicrophoneManager.d.ts +6 -0
- package/dist/src/devices/SpeakerManager.d.ts +2 -1
- package/dist/src/events/participant.d.ts +10 -3
- package/dist/src/gen/coordinator/index.d.ts +1363 -243
- package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
- package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
- package/dist/src/helpers/ensureExhausted.d.ts +1 -1
- package/dist/src/permissions/PermissionsContext.d.ts +2 -2
- package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
- package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
- package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
- package/dist/src/rtc/e2ee/events.d.ts +163 -0
- package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
- package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/mediaEngine.d.ts +23 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/store/CallState.d.ts +21 -0
- package/dist/src/types.d.ts +15 -6
- package/index.ts +3 -0
- package/package.json +1 -1
- package/src/Call.ts +160 -26
- package/src/__tests__/Call.test.ts +15 -0
- package/src/coordinator/connection/connection.ts +6 -3
- package/src/devices/AudioDeviceManager.ts +6 -0
- package/src/devices/CameraManager.ts +51 -4
- package/src/devices/DeviceManager.ts +17 -0
- package/src/devices/MicrophoneManager.ts +55 -5
- package/src/devices/ScreenShareManager.ts +3 -0
- package/src/devices/SpeakerManager.ts +42 -12
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
- package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
- package/src/devices/__tests__/mocks.ts +36 -3
- package/src/events/__tests__/participant.test.ts +134 -1
- package/src/events/callEventHandlers.ts +9 -3
- package/src/events/participant.ts +42 -7
- package/src/gen/coordinator/index.ts +1384 -244
- package/src/gen/video/sfu/models/models.ts +15 -0
- package/src/helpers/TypedEventEmitter.ts +71 -0
- package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
- package/src/helpers/ensureExhausted.ts +4 -2
- package/src/permissions/PermissionsContext.ts +10 -5
- package/src/rtc/BasePeerConnection.ts +12 -1
- package/src/rtc/Publisher.ts +8 -0
- package/src/rtc/Subscriber.ts +5 -0
- package/src/rtc/__tests__/Publisher.test.ts +101 -0
- package/src/rtc/__tests__/Subscriber.test.ts +89 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
- package/src/rtc/e2ee/E2EEManager.ts +12 -0
- package/src/rtc/e2ee/EncryptionManager.ts +336 -0
- package/src/rtc/e2ee/SPEC.md +586 -0
- package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
- package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
- package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
- package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
- package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
- package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
- package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
- package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
- package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
- package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
- package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
- package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
- package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
- package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
- package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
- package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
- package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
- package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
- package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
- package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
- package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
- package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
- package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
- package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
- package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
- package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
- package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
- package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
- package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
- package/src/rtc/e2ee/e2ee-worker.ts +27 -0
- package/src/rtc/e2ee/events.ts +179 -0
- package/src/rtc/e2ee/transformSupport.ts +35 -0
- package/src/rtc/helpers/rtcConfiguration.ts +4 -2
- package/src/rtc/index.ts +1 -0
- package/src/rtc/mediaEngine.ts +44 -0
- package/src/rtc/types.ts +2 -0
- package/src/store/CallState.ts +33 -1
- package/src/store/__tests__/CallState.test.ts +82 -1
- package/src/types.ts +23 -5
|
@@ -40,6 +40,14 @@ export interface CallState {
|
|
|
40
40
|
* @generated from protobuf field: repeated stream.video.sfu.models.Pin pins = 4;
|
|
41
41
|
*/
|
|
42
42
|
pins: Pin[];
|
|
43
|
+
/**
|
|
44
|
+
* e2ee_enabled is true when the call uses end-to-end encryption. Clients
|
|
45
|
+
* must enable their frame encryptor; the SFU forwards encrypted frames
|
|
46
|
+
* opaquely and server-side recording/transcription/broadcasting are disabled.
|
|
47
|
+
*
|
|
48
|
+
* @generated from protobuf field: bool e2ee_enabled = 5 [json_name = "e2eeEnabled"];
|
|
49
|
+
*/
|
|
50
|
+
e2EeEnabled: boolean;
|
|
43
51
|
}
|
|
44
52
|
/**
|
|
45
53
|
* @generated from protobuf message stream.video.sfu.models.ParticipantCount
|
|
@@ -1401,6 +1409,13 @@ class CallState$Type extends MessageType<CallState> {
|
|
|
1401
1409
|
repeat: 2 /*RepeatType.UNPACKED*/,
|
|
1402
1410
|
T: () => Pin,
|
|
1403
1411
|
},
|
|
1412
|
+
{
|
|
1413
|
+
no: 5,
|
|
1414
|
+
name: 'e2ee_enabled',
|
|
1415
|
+
kind: 'scalar',
|
|
1416
|
+
jsonName: 'e2eeEnabled',
|
|
1417
|
+
T: 8 /*ScalarType.BOOL*/,
|
|
1418
|
+
},
|
|
1404
1419
|
]);
|
|
1405
1420
|
}
|
|
1406
1421
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ScopedLogger, videoLoggerSystem } from '../logger';
|
|
2
|
+
|
|
3
|
+
export type EventMap = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
export type Listener<P> = (payload: P) => void | Promise<void>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tiny, type-safe event emitter.
|
|
9
|
+
*
|
|
10
|
+
* Usage styles:
|
|
11
|
+
* - compose: `private readonly events = new TypedEventEmitter<MyMap>()`
|
|
12
|
+
* - extend: `class Foo extends TypedEventEmitter<MyMap> {}`
|
|
13
|
+
*
|
|
14
|
+
* Listener exceptions (sync throw or rejected promise) are caught and logged,
|
|
15
|
+
* so one bad listener cannot break dispatch for the rest.
|
|
16
|
+
*/
|
|
17
|
+
export class TypedEventEmitter<M extends EventMap> {
|
|
18
|
+
protected readonly logger: ScopedLogger;
|
|
19
|
+
private readonly byEvent = new Map<keyof M, Set<Listener<any>>>();
|
|
20
|
+
|
|
21
|
+
constructor(logger: ScopedLogger | string = 'TypedEventEmitter') {
|
|
22
|
+
this.logger =
|
|
23
|
+
typeof logger === 'string' ? videoLoggerSystem.getLogger(logger) : logger;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
on = <E extends keyof M>(event: E, fn: Listener<M[E]>): (() => void) => {
|
|
27
|
+
let listeners = this.byEvent.get(event);
|
|
28
|
+
if (!listeners) {
|
|
29
|
+
listeners = new Set();
|
|
30
|
+
this.byEvent.set(event, listeners);
|
|
31
|
+
}
|
|
32
|
+
listeners.add(fn as Listener<any>);
|
|
33
|
+
return () => this.off(event, fn);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
off = <E extends keyof M>(event: E, fn: Listener<M[E]>): void => {
|
|
37
|
+
const listeners = this.byEvent.get(event);
|
|
38
|
+
if (!listeners) return;
|
|
39
|
+
listeners.delete(fn as Listener<any>);
|
|
40
|
+
if (listeners.size === 0) this.byEvent.delete(event);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
emit = <E extends keyof M>(event: E, payload: M[E]): void => {
|
|
44
|
+
const listeners = this.byEvent.get(event);
|
|
45
|
+
if (!listeners || listeners.size === 0) return;
|
|
46
|
+
for (const listener of [...listeners]) {
|
|
47
|
+
this.invoke(() => listener(payload), event);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
removeAllListeners = (event?: keyof M): void => {
|
|
52
|
+
if (event === undefined) {
|
|
53
|
+
this.byEvent.clear();
|
|
54
|
+
} else {
|
|
55
|
+
this.byEvent.delete(event);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
private invoke = (run: () => void | Promise<void>, event: keyof M): void => {
|
|
60
|
+
try {
|
|
61
|
+
const result = run();
|
|
62
|
+
if (result && typeof (result as Promise<void>).then === 'function') {
|
|
63
|
+
(result as Promise<void>).catch((err) => {
|
|
64
|
+
this.logger.warn(`Listener for '${String(event)}' rejected`, err);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
this.logger.warn(`Listener for '${String(event)}' threw`, err);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TypedEventEmitter } from '../TypedEventEmitter';
|
|
3
|
+
|
|
4
|
+
type TestEvents = {
|
|
5
|
+
hello: string;
|
|
6
|
+
ping: { n: number };
|
|
7
|
+
empty: undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
describe('TypedEventEmitter', () => {
|
|
11
|
+
let emitter: TypedEventEmitter<TestEvents>;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
emitter = new TypedEventEmitter<TestEvents>();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('invokes listeners registered with on()', () => {
|
|
18
|
+
const listener = vi.fn();
|
|
19
|
+
emitter.on('hello', listener);
|
|
20
|
+
emitter.emit('hello', 'world');
|
|
21
|
+
expect(listener).toHaveBeenCalledWith('world');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns an unsubscribe function from on()', () => {
|
|
25
|
+
const listener = vi.fn();
|
|
26
|
+
const unsubscribe = emitter.on('hello', listener);
|
|
27
|
+
unsubscribe();
|
|
28
|
+
emitter.emit('hello', 'world');
|
|
29
|
+
expect(listener).not.toHaveBeenCalled();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('removes listeners via off()', () => {
|
|
33
|
+
const listener = vi.fn();
|
|
34
|
+
emitter.on('hello', listener);
|
|
35
|
+
emitter.off('hello', listener);
|
|
36
|
+
emitter.emit('hello', 'world');
|
|
37
|
+
expect(listener).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('supports multiple listeners for the same event', () => {
|
|
41
|
+
const a = vi.fn();
|
|
42
|
+
const b = vi.fn();
|
|
43
|
+
emitter.on('ping', a);
|
|
44
|
+
emitter.on('ping', b);
|
|
45
|
+
emitter.emit('ping', { n: 1 });
|
|
46
|
+
expect(a).toHaveBeenCalledWith({ n: 1 });
|
|
47
|
+
expect(b).toHaveBeenCalledWith({ n: 1 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('deduplicates identical listener references', () => {
|
|
51
|
+
const listener = vi.fn();
|
|
52
|
+
emitter.on('hello', listener);
|
|
53
|
+
emitter.on('hello', listener);
|
|
54
|
+
emitter.emit('hello', 'once');
|
|
55
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('isolates a throwing listener from subsequent listeners', () => {
|
|
59
|
+
const bad = vi.fn(() => {
|
|
60
|
+
throw new Error('boom');
|
|
61
|
+
});
|
|
62
|
+
const good = vi.fn();
|
|
63
|
+
emitter.on('hello', bad);
|
|
64
|
+
emitter.on('hello', good);
|
|
65
|
+
expect(() => emitter.emit('hello', 'world')).not.toThrow();
|
|
66
|
+
expect(bad).toHaveBeenCalled();
|
|
67
|
+
expect(good).toHaveBeenCalledWith('world');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('isolates a rejecting async listener', async () => {
|
|
71
|
+
const rejected = vi.fn(async () => {
|
|
72
|
+
throw new Error('async boom');
|
|
73
|
+
});
|
|
74
|
+
const good = vi.fn();
|
|
75
|
+
emitter.on('hello', rejected);
|
|
76
|
+
emitter.on('hello', good);
|
|
77
|
+
expect(() => emitter.emit('hello', 'world')).not.toThrow();
|
|
78
|
+
expect(good).toHaveBeenCalled();
|
|
79
|
+
// allow microtask to settle; rejection should have been swallowed by the emitter
|
|
80
|
+
await Promise.resolve();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('is safe to call off() from within a listener (current emit still fires siblings)', () => {
|
|
84
|
+
const sibling = vi.fn();
|
|
85
|
+
const self = vi.fn(() => {
|
|
86
|
+
emitter.off('hello', self);
|
|
87
|
+
});
|
|
88
|
+
emitter.on('hello', self);
|
|
89
|
+
emitter.on('hello', sibling);
|
|
90
|
+
emitter.emit('hello', 'world');
|
|
91
|
+
expect(self).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(sibling).toHaveBeenCalledTimes(1);
|
|
93
|
+
// second emit should not invoke self again
|
|
94
|
+
emitter.emit('hello', 'again');
|
|
95
|
+
expect(self).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(sibling).toHaveBeenCalledTimes(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('is safe to add a listener from within a listener (added listener does not fire in current emit)', () => {
|
|
100
|
+
const later = vi.fn();
|
|
101
|
+
emitter.on('hello', () => {
|
|
102
|
+
emitter.on('hello', later);
|
|
103
|
+
});
|
|
104
|
+
emitter.emit('hello', 'first');
|
|
105
|
+
expect(later).not.toHaveBeenCalled();
|
|
106
|
+
emitter.emit('hello', 'second');
|
|
107
|
+
expect(later).toHaveBeenCalledWith('second');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('removeAllListeners() clears every subscription when called without args', () => {
|
|
111
|
+
const a = vi.fn();
|
|
112
|
+
const b = vi.fn();
|
|
113
|
+
emitter.on('hello', a);
|
|
114
|
+
emitter.on('ping', b);
|
|
115
|
+
emitter.removeAllListeners();
|
|
116
|
+
emitter.emit('hello', 'world');
|
|
117
|
+
emitter.emit('ping', { n: 1 });
|
|
118
|
+
expect(a).not.toHaveBeenCalled();
|
|
119
|
+
expect(b).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('removeAllListeners(event) clears only that event', () => {
|
|
123
|
+
const a = vi.fn();
|
|
124
|
+
const b = vi.fn();
|
|
125
|
+
emitter.on('hello', a);
|
|
126
|
+
emitter.on('ping', b);
|
|
127
|
+
emitter.removeAllListeners('hello');
|
|
128
|
+
emitter.emit('hello', 'world');
|
|
129
|
+
emitter.emit('ping', { n: 1 });
|
|
130
|
+
expect(a).not.toHaveBeenCalled();
|
|
131
|
+
expect(b).toHaveBeenCalledWith({ n: 1 });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { videoLoggerSystem } from '../logger';
|
|
2
2
|
|
|
3
|
-
export const ensureExhausted = (x: never, message
|
|
4
|
-
|
|
3
|
+
export const ensureExhausted = (x: never, message?: string) => {
|
|
4
|
+
if (message) {
|
|
5
|
+
videoLoggerSystem.getLogger('helpers').warn(message, x);
|
|
6
|
+
}
|
|
5
7
|
};
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CallSettingsResponse,
|
|
3
|
+
OwnCapability,
|
|
4
|
+
RequestPermissionRequestPermissionsEnum,
|
|
5
|
+
} from '../gen/coordinator';
|
|
2
6
|
import { TrackType } from '../gen/video/sfu/models/models';
|
|
3
7
|
import { ensureExhausted } from '../helpers/ensureExhausted';
|
|
4
8
|
|
|
@@ -70,20 +74,21 @@ export class PermissionsContext {
|
|
|
70
74
|
* @param settings the call settings to check against (optional).
|
|
71
75
|
*/
|
|
72
76
|
canRequest = (
|
|
73
|
-
permission:
|
|
77
|
+
permission: RequestPermissionRequestPermissionsEnum,
|
|
74
78
|
settings: CallSettingsResponse | undefined = this.settings,
|
|
75
79
|
) => {
|
|
76
80
|
if (!settings) return false;
|
|
77
81
|
|
|
78
82
|
const { audio, video, screensharing } = settings;
|
|
79
83
|
switch (permission) {
|
|
80
|
-
case
|
|
84
|
+
case RequestPermissionRequestPermissionsEnum.SEND_AUDIO:
|
|
81
85
|
return audio.access_request_enabled;
|
|
82
|
-
case
|
|
86
|
+
case RequestPermissionRequestPermissionsEnum.SEND_VIDEO:
|
|
83
87
|
return video.access_request_enabled;
|
|
84
|
-
case
|
|
88
|
+
case RequestPermissionRequestPermissionsEnum.SCREENSHARE:
|
|
85
89
|
return screensharing.access_request_enabled;
|
|
86
90
|
default:
|
|
91
|
+
ensureExhausted(permission);
|
|
87
92
|
return false;
|
|
88
93
|
}
|
|
89
94
|
};
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
TrackType,
|
|
9
9
|
WebsocketReconnectStrategy,
|
|
10
10
|
} from '../gen/video/sfu/models/models';
|
|
11
|
+
import type { E2EEManager } from './e2ee/E2EEManager';
|
|
12
|
+
import { hasInsertableStreams } from './e2ee/transformSupport';
|
|
11
13
|
import { NegotiationError } from './NegotiationError';
|
|
12
14
|
import { StreamSfuClient } from '../StreamSfuClient';
|
|
13
15
|
import { AllSfuEvents, Dispatcher } from './Dispatcher';
|
|
@@ -35,6 +37,7 @@ export abstract class BasePeerConnection {
|
|
|
35
37
|
protected readonly state: CallState;
|
|
36
38
|
protected readonly dispatcher: Dispatcher;
|
|
37
39
|
protected readonly clientPublishOptions?: ClientPublishOptions;
|
|
40
|
+
protected readonly e2ee?: E2EEManager;
|
|
38
41
|
protected tag: string;
|
|
39
42
|
protected sfuClient: StreamSfuClient;
|
|
40
43
|
|
|
@@ -75,6 +78,7 @@ export abstract class BasePeerConnection {
|
|
|
75
78
|
tag,
|
|
76
79
|
enableTracing,
|
|
77
80
|
clientPublishOptions,
|
|
81
|
+
e2ee,
|
|
78
82
|
iceRestartDelay = 2500,
|
|
79
83
|
statsTimestampDriftThresholdMs = 0,
|
|
80
84
|
}: BasePeerConnectionOpts,
|
|
@@ -85,6 +89,7 @@ export abstract class BasePeerConnection {
|
|
|
85
89
|
this.dispatcher = dispatcher;
|
|
86
90
|
this.iceRestartDelay = iceRestartDelay;
|
|
87
91
|
this.clientPublishOptions = clientPublishOptions;
|
|
92
|
+
this.e2ee = e2ee;
|
|
88
93
|
this.tag = tag;
|
|
89
94
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
90
95
|
this.onIceConnected = onIceConnected;
|
|
@@ -114,7 +119,13 @@ export abstract class BasePeerConnection {
|
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
private createPeerConnection = (connectionConfig?: RTCConfiguration) => {
|
|
117
|
-
const
|
|
122
|
+
const config: RTCConfiguration = { ...connectionConfig };
|
|
123
|
+
// The legacy Insertable Streams path requires this non-standard flag.
|
|
124
|
+
if (this.e2ee && hasInsertableStreams()) {
|
|
125
|
+
// @ts-expect-error not part of the standard lib yet
|
|
126
|
+
config.encodedInsertableStreams = true;
|
|
127
|
+
}
|
|
128
|
+
const pc = new RTCPeerConnection(config);
|
|
118
129
|
pc.addEventListener('icecandidate', this.onIceCandidate);
|
|
119
130
|
pc.addEventListener('icecandidateerror', this.onIceCandidateError);
|
|
120
131
|
pc.addEventListener(
|
package/src/rtc/Publisher.ts
CHANGED
|
@@ -166,6 +166,14 @@ export class Publisher extends BasePeerConnection {
|
|
|
166
166
|
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
167
167
|
'maintain-framerate';
|
|
168
168
|
await transceiver.sender.setParameters(params);
|
|
169
|
+
if (this.e2ee) {
|
|
170
|
+
this.e2ee.encrypt(
|
|
171
|
+
transceiver.sender,
|
|
172
|
+
publishOption.codec?.name.toLowerCase(),
|
|
173
|
+
TrackType[publishOption.trackType],
|
|
174
|
+
);
|
|
175
|
+
this.logger.debug('E2EE encryptor attached to sender');
|
|
176
|
+
}
|
|
169
177
|
|
|
170
178
|
await this.negotiate();
|
|
171
179
|
};
|
package/src/rtc/Subscriber.ts
CHANGED
|
@@ -151,8 +151,13 @@ export class Subscriber extends BasePeerConnection {
|
|
|
151
151
|
trackLookupPrefix: trackId,
|
|
152
152
|
track: primaryStream,
|
|
153
153
|
trackType,
|
|
154
|
+
receiver: this.e2ee ? e.receiver : undefined,
|
|
154
155
|
});
|
|
155
156
|
return;
|
|
157
|
+
} else if (this.e2ee) {
|
|
158
|
+
const { userId } = participantToUpdate;
|
|
159
|
+
this.e2ee.decrypt(e.receiver, userId, TrackType[trackType]);
|
|
160
|
+
this.logger.debug('E2EE decryptor attached to receiver', userId);
|
|
156
161
|
}
|
|
157
162
|
|
|
158
163
|
const streamKindProp = trackTypeToParticipantStreamKey(trackType);
|
|
@@ -146,6 +146,50 @@ describe('Publisher', () => {
|
|
|
146
146
|
expect(negotiateSpy).toHaveBeenCalled();
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
it('should attach an encryptor when E2EE manager is provided', async () => {
|
|
150
|
+
const e2eeMock = {
|
|
151
|
+
encrypt: vi.fn(),
|
|
152
|
+
decrypt: vi.fn(),
|
|
153
|
+
};
|
|
154
|
+
publisher.dispose();
|
|
155
|
+
publisher = new Publisher(
|
|
156
|
+
{
|
|
157
|
+
sfuClient,
|
|
158
|
+
dispatcher,
|
|
159
|
+
state,
|
|
160
|
+
tag: 'test',
|
|
161
|
+
enableTracing: false,
|
|
162
|
+
e2ee: e2eeMock,
|
|
163
|
+
},
|
|
164
|
+
[
|
|
165
|
+
{
|
|
166
|
+
id: 1,
|
|
167
|
+
trackType: TrackType.VIDEO,
|
|
168
|
+
bitrate: 1000,
|
|
169
|
+
// @ts-expect-error - incomplete data
|
|
170
|
+
codec: { name: 'vp9' },
|
|
171
|
+
fps: 30,
|
|
172
|
+
maxTemporalLayers: 3,
|
|
173
|
+
maxSpatialLayers: 3,
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const track = new MediaStreamTrack();
|
|
179
|
+
const clone = new MediaStreamTrack();
|
|
180
|
+
vi.spyOn(track, 'clone').mockReturnValue(clone);
|
|
181
|
+
// @ts-expect-error - private method
|
|
182
|
+
vi.spyOn(publisher, 'negotiate').mockResolvedValue();
|
|
183
|
+
|
|
184
|
+
await publisher.publish(track, TrackType.VIDEO);
|
|
185
|
+
|
|
186
|
+
expect(e2eeMock.encrypt).toHaveBeenCalledWith(
|
|
187
|
+
expect.anything(), // sender
|
|
188
|
+
'vp9',
|
|
189
|
+
'VIDEO',
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
149
193
|
it('should update an existing transceiver for a new track', async () => {
|
|
150
194
|
const track = new MediaStreamTrack();
|
|
151
195
|
const clone = new MediaStreamTrack();
|
|
@@ -239,6 +283,63 @@ describe('Publisher', () => {
|
|
|
239
283
|
});
|
|
240
284
|
});
|
|
241
285
|
|
|
286
|
+
describe('E2EE peer connection config', () => {
|
|
287
|
+
const e2ee = { encrypt: vi.fn(), decrypt: vi.fn() };
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Build a publisher and return the RTCConfiguration it constructed its peer
|
|
291
|
+
* connection with. The publisher from the outer `beforeEach` already
|
|
292
|
+
* consumed a call, hence the last one rather than the first.
|
|
293
|
+
*/
|
|
294
|
+
const configOf = async (opts: { e2ee?: typeof e2ee }) => {
|
|
295
|
+
await publisher.dispose();
|
|
296
|
+
publisher = new Publisher(
|
|
297
|
+
{
|
|
298
|
+
sfuClient,
|
|
299
|
+
dispatcher,
|
|
300
|
+
state,
|
|
301
|
+
tag: 'test',
|
|
302
|
+
enableTracing: false,
|
|
303
|
+
...opts,
|
|
304
|
+
},
|
|
305
|
+
[],
|
|
306
|
+
);
|
|
307
|
+
const calls = vi.mocked(globalThis.RTCPeerConnection).mock.calls;
|
|
308
|
+
return calls[calls.length - 1][0] as Record<string, unknown> | undefined;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const withInsertableStreams = (available: boolean) => {
|
|
312
|
+
if (available) {
|
|
313
|
+
Object.assign(RTCRtpSender.prototype, {
|
|
314
|
+
createEncodedStreams: vi.fn(),
|
|
315
|
+
});
|
|
316
|
+
} else {
|
|
317
|
+
// @ts-expect-error - non-standard property from the mock prototype
|
|
318
|
+
delete RTCRtpSender.prototype.createEncodedStreams;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
afterEach(() => withInsertableStreams(false));
|
|
323
|
+
|
|
324
|
+
it('enables encodedInsertableStreams when a manager is attached', async () => {
|
|
325
|
+
withInsertableStreams(true);
|
|
326
|
+
const config = await configOf({ e2ee });
|
|
327
|
+
expect(config?.encodedInsertableStreams).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('leaves the flag off when no manager is attached', async () => {
|
|
331
|
+
withInsertableStreams(true);
|
|
332
|
+
const config = await configOf({});
|
|
333
|
+
expect(config?.encodedInsertableStreams).toBeUndefined();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('leaves the flag off on browsers without Insertable Streams', async () => {
|
|
337
|
+
withInsertableStreams(false);
|
|
338
|
+
const config = await configOf({ e2ee });
|
|
339
|
+
expect(config?.encodedInsertableStreams).toBeUndefined();
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
242
343
|
describe('Event Handling', () => {
|
|
243
344
|
it('handles changePublishQuality events', () => {
|
|
244
345
|
publisher['changePublishQuality'] = vi.fn();
|
|
@@ -17,6 +17,7 @@ import { NegotiationError } from '../NegotiationError';
|
|
|
17
17
|
import { ReconnectReason } from '../types';
|
|
18
18
|
import { IceTrickleBuffer } from '../IceTrickleBuffer';
|
|
19
19
|
import { StreamClient } from '../../coordinator/connection/client';
|
|
20
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
20
21
|
|
|
21
22
|
vi.mock('../../StreamSfuClient', () => {
|
|
22
23
|
console.log('MOCKING StreamSfuClient');
|
|
@@ -522,6 +523,94 @@ describe('Subscriber', () => {
|
|
|
522
523
|
expect(baseTrack.stop).toHaveBeenCalled();
|
|
523
524
|
expect(baseStream.removeTrack).toHaveBeenCalledWith(baseTrack);
|
|
524
525
|
});
|
|
526
|
+
|
|
527
|
+
it('should store receiver in orphaned track when E2EE is enabled', () => {
|
|
528
|
+
const e2eeMock = {
|
|
529
|
+
encrypt: vi.fn(),
|
|
530
|
+
decrypt: vi.fn(),
|
|
531
|
+
};
|
|
532
|
+
subscriber.dispose();
|
|
533
|
+
const e2eeState = new CallState();
|
|
534
|
+
subscriber = new Subscriber({
|
|
535
|
+
sfuClient,
|
|
536
|
+
dispatcher,
|
|
537
|
+
state: e2eeState,
|
|
538
|
+
connectionConfig: { iceServers: [] },
|
|
539
|
+
tag: 'test',
|
|
540
|
+
enableTracing: true,
|
|
541
|
+
e2ee: e2eeMock,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const mediaStream = new MediaStream();
|
|
545
|
+
const mediaStreamTrack = new MediaStreamTrack();
|
|
546
|
+
const receiver = {};
|
|
547
|
+
// @ts-expect-error - mock
|
|
548
|
+
mediaStream.id = 'orphan-prefix:TRACK_TYPE_VIDEO';
|
|
549
|
+
|
|
550
|
+
const registerOrphanedTrackSpy = vi.spyOn(
|
|
551
|
+
e2eeState,
|
|
552
|
+
'registerOrphanedTrack',
|
|
553
|
+
);
|
|
554
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
555
|
+
// @ts-expect-error - incomplete mock
|
|
556
|
+
onTrack({ streams: [mediaStream], track: mediaStreamTrack, receiver });
|
|
557
|
+
|
|
558
|
+
// Decrypt is NOT called immediately for orphaned tracks
|
|
559
|
+
expect(e2eeMock.decrypt).not.toHaveBeenCalled();
|
|
560
|
+
// Receiver is stored with the orphaned track for later reconciliation
|
|
561
|
+
expect(registerOrphanedTrackSpy).toHaveBeenCalledWith({
|
|
562
|
+
id: mediaStream.id,
|
|
563
|
+
trackLookupPrefix: 'orphan-prefix',
|
|
564
|
+
track: mediaStream,
|
|
565
|
+
trackType: TrackType.VIDEO,
|
|
566
|
+
receiver,
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('should decrypt with userId when participant is found', () => {
|
|
571
|
+
const e2eeMock = {
|
|
572
|
+
encrypt: vi.fn(),
|
|
573
|
+
decrypt: vi.fn(),
|
|
574
|
+
};
|
|
575
|
+
subscriber.dispose();
|
|
576
|
+
const e2eeState = new CallState();
|
|
577
|
+
subscriber = new Subscriber({
|
|
578
|
+
sfuClient,
|
|
579
|
+
dispatcher,
|
|
580
|
+
state: e2eeState,
|
|
581
|
+
connectionConfig: { iceServers: [] },
|
|
582
|
+
tag: 'test',
|
|
583
|
+
enableTracing: true,
|
|
584
|
+
e2ee: e2eeMock,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Register a participant whose trackLookupPrefix matches the stream
|
|
588
|
+
e2eeState.updateOrAddParticipant(
|
|
589
|
+
'session-id',
|
|
590
|
+
fromPartial({
|
|
591
|
+
sessionId: 'session-id',
|
|
592
|
+
userId: 'real-user-id',
|
|
593
|
+
trackLookupPrefix: '123',
|
|
594
|
+
}),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
const mediaStream = new MediaStream();
|
|
598
|
+
const mediaStreamTrack = new MediaStreamTrack();
|
|
599
|
+
const receiver = {};
|
|
600
|
+
// @ts-expect-error - mock
|
|
601
|
+
mediaStream.id = '123:TRACK_TYPE_VIDEO';
|
|
602
|
+
|
|
603
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
604
|
+
// @ts-expect-error - incomplete mock
|
|
605
|
+
onTrack({ streams: [mediaStream], track: mediaStreamTrack, receiver });
|
|
606
|
+
|
|
607
|
+
// Uses the participant's userId (not trackLookupPrefix) for key lookup
|
|
608
|
+
expect(e2eeMock.decrypt).toHaveBeenCalledWith(
|
|
609
|
+
receiver,
|
|
610
|
+
'real-user-id',
|
|
611
|
+
'VIDEO',
|
|
612
|
+
);
|
|
613
|
+
});
|
|
525
614
|
});
|
|
526
615
|
|
|
527
616
|
describe('interruptedTracks', () => {
|
|
@@ -57,6 +57,7 @@ const RTCRtpTransceiverMock = vi.fn(function (): Partial<RTCRtpTransceiver> {
|
|
|
57
57
|
replaceTrack: vi.fn(),
|
|
58
58
|
getParameters: vi.fn().mockReturnValue({}),
|
|
59
59
|
setParameters: vi.fn(),
|
|
60
|
+
transform: null,
|
|
60
61
|
},
|
|
61
62
|
setCodecPreferences: vi.fn(),
|
|
62
63
|
mid: '',
|
|
@@ -80,6 +81,7 @@ const RTCRtpReceiverMock = vi.fn(function (): Partial<typeof RTCRtpReceiver> {
|
|
|
80
81
|
getCapabilities: vi.fn(),
|
|
81
82
|
};
|
|
82
83
|
});
|
|
84
|
+
RTCRtpReceiverMock.prototype.transform = null;
|
|
83
85
|
vi.stubGlobal('RTCRtpReceiver', RTCRtpReceiverMock);
|
|
84
86
|
|
|
85
87
|
const RTCRtpSenderMock = vi.fn(function (): Partial<typeof RTCRtpSender> {
|
|
@@ -89,8 +91,37 @@ const RTCRtpSenderMock = vi.fn(function (): Partial<typeof RTCRtpSender> {
|
|
|
89
91
|
track: vi.fn(),
|
|
90
92
|
};
|
|
91
93
|
});
|
|
94
|
+
RTCRtpSenderMock.prototype.transform = null;
|
|
92
95
|
vi.stubGlobal('RTCRtpSender', RTCRtpSenderMock);
|
|
93
96
|
|
|
97
|
+
const WorkerMock = vi.fn(function (): Partial<Worker> {
|
|
98
|
+
return {
|
|
99
|
+
postMessage: vi.fn(),
|
|
100
|
+
terminate: vi.fn(),
|
|
101
|
+
addEventListener: vi.fn(),
|
|
102
|
+
removeEventListener: vi.fn(),
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
vi.stubGlobal('Worker', WorkerMock);
|
|
106
|
+
|
|
107
|
+
const RTCRtpScriptTransformMock = vi.fn(function (
|
|
108
|
+
worker: Worker,
|
|
109
|
+
options?: unknown,
|
|
110
|
+
): Partial<RTCRtpScriptTransform> {
|
|
111
|
+
return {
|
|
112
|
+
worker,
|
|
113
|
+
options,
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
vi.stubGlobal('RTCRtpScriptTransform', RTCRtpScriptTransformMock);
|
|
117
|
+
|
|
118
|
+
if (typeof URL !== 'undefined' && typeof URL.createObjectURL !== 'function') {
|
|
119
|
+
Object.defineProperty(URL, 'createObjectURL', {
|
|
120
|
+
configurable: true,
|
|
121
|
+
value: vi.fn(() => 'blob:stream-video-e2ee'),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
94
125
|
const AudioContextMock = vi.fn(function (): Partial<AudioContext> {
|
|
95
126
|
return {
|
|
96
127
|
state: 'suspended',
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the RTC layer needs to attach E2EE to a track. {@link EncryptionManager}
|
|
3
|
+
* is the built-in AES-GCM implementation, but `Call.setE2EEManager` takes any:
|
|
4
|
+
* an integrator can plug in another scheme, RFC 9605 SFrame for example, by
|
|
5
|
+
* attaching their own encoded transform in these two methods.
|
|
6
|
+
*/
|
|
7
|
+
export interface E2EEManager {
|
|
8
|
+
/** `trackType` only groups perf stats, keeping a camera and screen share apart. */
|
|
9
|
+
encrypt(sender: RTCRtpSender, codec?: string, trackType?: string): void;
|
|
10
|
+
/** `trackType` only groups perf stats, keeping a peer's audio and video apart. */
|
|
11
|
+
decrypt(receiver: RTCRtpReceiver, userId: string, trackType?: string): void;
|
|
12
|
+
}
|