@stream-io/video-client 1.57.0 → 1.59.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 +28 -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 +1141 -117
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1196 -115
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.es.js +1174 -117
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +66 -9
- package/dist/src/StreamVideoClient.d.ts +9 -1
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/devices/CameraManager.d.ts +8 -1
- package/dist/src/devices/DeviceManager.d.ts +2 -1
- package/dist/src/devices/MicrophoneManager.d.ts +8 -1
- 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 +1386 -240
- package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
- package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
- package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
- package/dist/src/helpers/concurrency.d.ts +8 -0
- package/dist/src/helpers/ensureExhausted.d.ts +1 -1
- package/dist/src/helpers/loopback.d.ts +55 -0
- package/dist/src/permissions/PermissionsContext.d.ts +2 -2
- package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
- package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
- package/dist/src/rtc/Subscriber.d.ts +5 -0
- 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 +5 -0
- package/package.json +1 -1
- package/src/Call.ts +311 -155
- package/src/StreamVideoClient.ts +35 -8
- package/src/__tests__/Call.lifecycle.test.ts +33 -2
- package/src/__tests__/Call.publishing.test.ts +42 -1
- package/src/__tests__/Call.test.ts +15 -0
- package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
- package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
- package/src/__tests__/StreamVideoClient.test.ts +14 -12
- package/src/coordinator/connection/connection.ts +6 -3
- package/src/devices/AudioDeviceManager.ts +6 -0
- package/src/devices/CameraManager.ts +67 -8
- package/src/devices/DeviceManager.ts +24 -4
- package/src/devices/MicrophoneManager.ts +66 -8
- package/src/devices/ScreenShareManager.ts +3 -0
- package/src/devices/SpeakerManager.ts +42 -12
- package/src/devices/__tests__/CameraManager.test.ts +27 -1
- package/src/devices/__tests__/DeviceManager.test.ts +49 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
- 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/call.ts +6 -3
- package/src/events/callEventHandlers.ts +9 -3
- package/src/events/participant.ts +42 -7
- package/src/gen/coordinator/index.ts +1402 -236
- package/src/gen/video/sfu/models/models.ts +15 -0
- package/src/helpers/MediaStreamRecorder.ts +104 -0
- package/src/helpers/TypedEventEmitter.ts +71 -0
- package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
- package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
- package/src/helpers/__tests__/concurrency.test.ts +74 -0
- package/src/helpers/__tests__/loopback.test.ts +367 -0
- package/src/helpers/concurrency.ts +24 -0
- package/src/helpers/ensureExhausted.ts +4 -2
- package/src/helpers/loopback.ts +200 -0
- package/src/permissions/PermissionsContext.ts +10 -5
- package/src/reporting/ClientEventReporter.ts +15 -28
- package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
- package/src/rtc/BasePeerConnection.ts +12 -1
- package/src/rtc/Publisher.ts +8 -0
- package/src/rtc/Subscriber.ts +13 -0
- package/src/rtc/__tests__/Publisher.test.ts +101 -0
- package/src/rtc/__tests__/Subscriber.test.ts +190 -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
|
@@ -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
|
};
|
|
@@ -19,6 +19,11 @@ import type {
|
|
|
19
19
|
BrowserPermissionState,
|
|
20
20
|
} from '../devices/BrowserPermission';
|
|
21
21
|
import { getCurrentValue } from '../store/rxUtils';
|
|
22
|
+
import type {
|
|
23
|
+
ClientEvent,
|
|
24
|
+
ReportClientEventRequest,
|
|
25
|
+
ReportClientEventResponse,
|
|
26
|
+
} from '../gen/coordinator';
|
|
22
27
|
|
|
23
28
|
export type ClientEventPeerConnection = 'publish' | 'subscribe';
|
|
24
29
|
|
|
@@ -55,7 +60,6 @@ export type CallReportContext = {
|
|
|
55
60
|
callId: string;
|
|
56
61
|
getSfuId: () => string;
|
|
57
62
|
getCallSessionId: () => string;
|
|
58
|
-
getUserSessionId: () => string;
|
|
59
63
|
};
|
|
60
64
|
|
|
61
65
|
export type ClientEventReporterOptions = {
|
|
@@ -80,7 +84,6 @@ type StagePairState = {
|
|
|
80
84
|
|
|
81
85
|
type PeerConnectionPairState = StagePairState & {
|
|
82
86
|
sfuId: string;
|
|
83
|
-
userSessionId: string;
|
|
84
87
|
wasPreviouslyConnected: boolean;
|
|
85
88
|
lastIceState?: RTCIceConnectionState;
|
|
86
89
|
};
|
|
@@ -192,9 +195,7 @@ export class ClientEventReporter {
|
|
|
192
195
|
this.coordinatorWsPair = undefined;
|
|
193
196
|
};
|
|
194
197
|
|
|
195
|
-
private buildCoordinatorWsCommon = (
|
|
196
|
-
pair: StagePairState,
|
|
197
|
-
): Record<string, unknown> => ({
|
|
198
|
+
private buildCoordinatorWsCommon = (pair: StagePairState): ClientEvent => ({
|
|
198
199
|
user_id: pair.userIdSnapshot ?? this.streamClient.userID,
|
|
199
200
|
stage: 'CoordinatorWS',
|
|
200
201
|
stage_id: pair.sid,
|
|
@@ -383,7 +384,6 @@ export class ClientEventReporter {
|
|
|
383
384
|
user_id: this.streamClient.userID || this.coordinatorConnectUserId,
|
|
384
385
|
type: ctx?.callType,
|
|
385
386
|
id: ctx?.callId,
|
|
386
|
-
call_cid: cid,
|
|
387
387
|
stage: 'JoinInitiated',
|
|
388
388
|
join_attempt_id: joinAttemptId,
|
|
389
389
|
...(coordinatorConnectId && {
|
|
@@ -608,7 +608,6 @@ export class ClientEventReporter {
|
|
|
608
608
|
startedAt: Date.now(),
|
|
609
609
|
joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
|
|
610
610
|
sfuId: this.getSfuId(cid),
|
|
611
|
-
userSessionId: this.getUserSessionId(cid),
|
|
612
611
|
wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
|
|
613
612
|
lastIceState: iceConnectionState,
|
|
614
613
|
};
|
|
@@ -620,9 +619,6 @@ export class ClientEventReporter {
|
|
|
620
619
|
peer_connection: role,
|
|
621
620
|
was_previously_connected: pair.wasPreviouslyConnected,
|
|
622
621
|
...(pair.sfuId && { sfu_id: pair.sfuId }),
|
|
623
|
-
...(pair.userSessionId && {
|
|
624
|
-
user_session_id: pair.userSessionId,
|
|
625
|
-
}),
|
|
626
622
|
event_type: 'initiated',
|
|
627
623
|
});
|
|
628
624
|
};
|
|
@@ -641,9 +637,6 @@ export class ClientEventReporter {
|
|
|
641
637
|
peer_connection: role,
|
|
642
638
|
was_previously_connected: pair.wasPreviouslyConnected,
|
|
643
639
|
...(pair.sfuId && { sfu_id: pair.sfuId }),
|
|
644
|
-
...(pair.userSessionId && {
|
|
645
|
-
user_session_id: pair.userSessionId,
|
|
646
|
-
}),
|
|
647
640
|
event_type: 'completed',
|
|
648
641
|
outcome: 'success',
|
|
649
642
|
retry_count_attempt: 0,
|
|
@@ -667,9 +660,6 @@ export class ClientEventReporter {
|
|
|
667
660
|
...this.sessionIdField(cid),
|
|
668
661
|
peer_connection: role,
|
|
669
662
|
was_previously_connected: pair.wasPreviouslyConnected,
|
|
670
|
-
...(pair.userSessionId && {
|
|
671
|
-
user_session_id: pair.userSessionId,
|
|
672
|
-
}),
|
|
673
663
|
...(pair.sfuId && { sfu_id: pair.sfuId }),
|
|
674
664
|
event_type: 'completed',
|
|
675
665
|
outcome: 'failure',
|
|
@@ -685,10 +675,7 @@ export class ClientEventReporter {
|
|
|
685
675
|
private getSfuId = (cid: string): string =>
|
|
686
676
|
this.callContexts.get(cid)?.getSfuId() ?? '';
|
|
687
677
|
|
|
688
|
-
private
|
|
689
|
-
this.callContexts.get(cid)?.getUserSessionId() ?? '';
|
|
690
|
-
|
|
691
|
-
private sessionIdField = (cid: string): Record<string, unknown> => {
|
|
678
|
+
private sessionIdField = (cid: string): { call_session_id?: string } => {
|
|
692
679
|
const callSessionId = this.callContexts.get(cid)?.getCallSessionId() ?? '';
|
|
693
680
|
return callSessionId ? { call_session_id: callSessionId } : {};
|
|
694
681
|
};
|
|
@@ -697,14 +684,13 @@ export class ClientEventReporter {
|
|
|
697
684
|
cid: string,
|
|
698
685
|
stage: ClientEventStage,
|
|
699
686
|
pair: StagePairState,
|
|
700
|
-
):
|
|
687
|
+
): ClientEvent => {
|
|
701
688
|
const ctx = this.callContexts.get(cid);
|
|
702
689
|
const coordinatorConnectId = this.coordinatorConnectId;
|
|
703
690
|
return {
|
|
704
691
|
user_id: this.streamClient.userID || this.coordinatorConnectUserId,
|
|
705
692
|
type: ctx?.callType ?? '',
|
|
706
693
|
id: ctx?.callId ?? '',
|
|
707
|
-
call_cid: cid,
|
|
708
694
|
stage,
|
|
709
695
|
stage_id: pair.sid,
|
|
710
696
|
...(pair.joinAttemptIdSnapshot && {
|
|
@@ -719,22 +705,23 @@ export class ClientEventReporter {
|
|
|
719
705
|
};
|
|
720
706
|
};
|
|
721
707
|
|
|
722
|
-
private send = (body:
|
|
708
|
+
private send = (body: ClientEvent) => {
|
|
723
709
|
if (!this.enabled) return;
|
|
724
710
|
void this.sendWithRetry(body);
|
|
725
711
|
};
|
|
726
712
|
|
|
727
|
-
private sendForCall = (cid: string, body:
|
|
713
|
+
private sendForCall = (cid: string, body: ClientEvent) => {
|
|
728
714
|
if (!this.callContexts.has(cid)) return;
|
|
729
715
|
this.send(body);
|
|
730
716
|
};
|
|
731
717
|
|
|
732
|
-
private sendWithRetry = async (
|
|
733
|
-
body: Record<string, unknown>,
|
|
734
|
-
): Promise<boolean> => {
|
|
718
|
+
private sendWithRetry = async (body: ClientEvent): Promise<boolean> => {
|
|
735
719
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
736
720
|
try {
|
|
737
|
-
await this.streamClient.doAxiosRequest
|
|
721
|
+
await this.streamClient.doAxiosRequest<
|
|
722
|
+
ReportClientEventResponse,
|
|
723
|
+
ReportClientEventRequest
|
|
724
|
+
>(
|
|
738
725
|
'post',
|
|
739
726
|
'/call_client_event',
|
|
740
727
|
{ events: [body] },
|
|
@@ -77,7 +77,6 @@ describe('ClientEventReporter', () => {
|
|
|
77
77
|
callId: 'call-1',
|
|
78
78
|
getCallSessionId: () => 'session-1',
|
|
79
79
|
getSfuId: () => 'sfu-1',
|
|
80
|
-
getUserSessionId: () => 'user-session-1',
|
|
81
80
|
};
|
|
82
81
|
reporter.registerCall(cid, ctx);
|
|
83
82
|
});
|
|
@@ -91,7 +90,7 @@ describe('ClientEventReporter', () => {
|
|
|
91
90
|
expect(events).toHaveLength(2);
|
|
92
91
|
expect(events[0]).toMatchObject({
|
|
93
92
|
event_type: 'initiated',
|
|
94
|
-
|
|
93
|
+
id: 'call-1',
|
|
95
94
|
user_id: 'user-1',
|
|
96
95
|
coordinator_connect_id: connectId,
|
|
97
96
|
join_reason: 'first-attempt',
|
|
@@ -523,7 +522,6 @@ describe('ClientEventReporter', () => {
|
|
|
523
522
|
peer_connection: 'publish',
|
|
524
523
|
was_previously_connected: false,
|
|
525
524
|
sfu_id: 'sfu-1',
|
|
526
|
-
user_session_id: 'user-session-1',
|
|
527
525
|
});
|
|
528
526
|
expect(events[1]).toMatchObject({
|
|
529
527
|
event_type: 'completed',
|
|
@@ -712,7 +710,6 @@ describe('ClientEventReporter', () => {
|
|
|
712
710
|
callId: 'call-2',
|
|
713
711
|
getCallSessionId: () => 'session-2',
|
|
714
712
|
getSfuId: () => 'sfu-2',
|
|
715
|
-
getUserSessionId: () => 'user-session-2',
|
|
716
713
|
});
|
|
717
714
|
|
|
718
715
|
reporter.startCorrelation(cid, 'first-attempt');
|
|
@@ -722,10 +719,10 @@ describe('ClientEventReporter', () => {
|
|
|
722
719
|
await flush();
|
|
723
720
|
|
|
724
721
|
const ws1 = postedEvents().filter(
|
|
725
|
-
(e) => e.stage === 'WSJoin' && e.
|
|
722
|
+
(e) => e.stage === 'WSJoin' && e.id === 'call-1',
|
|
726
723
|
);
|
|
727
724
|
const ws2 = postedEvents().filter(
|
|
728
|
-
(e) => e.stage === 'WSJoin' && e.
|
|
725
|
+
(e) => e.stage === 'WSJoin' && e.id === 'call-2',
|
|
729
726
|
);
|
|
730
727
|
expect(ws1).toHaveLength(2);
|
|
731
728
|
expect(ws2).toHaveLength(2);
|
|
@@ -806,7 +803,6 @@ describe('ClientEventReporter (disabled)', () => {
|
|
|
806
803
|
callId: 'call-1',
|
|
807
804
|
getCallSessionId: () => 'session-1',
|
|
808
805
|
getSfuId: () => 'sfu-1',
|
|
809
|
-
getUserSessionId: () => 'user-session-1',
|
|
810
806
|
});
|
|
811
807
|
});
|
|
812
808
|
|
|
@@ -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
|
@@ -94,6 +94,13 @@ export class Subscriber extends BasePeerConnection {
|
|
|
94
94
|
}
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Whether the given stream is one the SFU echoed back to us
|
|
99
|
+
* (`allowOwnTracksLoopback`) rather than a local capture stream.
|
|
100
|
+
*/
|
|
101
|
+
isSelfSubscribedStream = (stream: MediaStream | undefined): boolean =>
|
|
102
|
+
!!stream && !!this.trackedStreams?.has(stream);
|
|
103
|
+
|
|
97
104
|
private handleOnTrack = (e: RTCTrackEvent) => {
|
|
98
105
|
const { streams, track } = e;
|
|
99
106
|
const [primaryStream] = streams;
|
|
@@ -151,8 +158,13 @@ export class Subscriber extends BasePeerConnection {
|
|
|
151
158
|
trackLookupPrefix: trackId,
|
|
152
159
|
track: primaryStream,
|
|
153
160
|
trackType,
|
|
161
|
+
receiver: this.e2ee ? e.receiver : undefined,
|
|
154
162
|
});
|
|
155
163
|
return;
|
|
164
|
+
} else if (this.e2ee) {
|
|
165
|
+
const { userId } = participantToUpdate;
|
|
166
|
+
this.e2ee.decrypt(e.receiver, userId, TrackType[trackType]);
|
|
167
|
+
this.logger.debug('E2EE decryptor attached to receiver', userId);
|
|
156
168
|
}
|
|
157
169
|
|
|
158
170
|
const streamKindProp = trackTypeToParticipantStreamKey(trackType);
|
|
@@ -207,6 +219,7 @@ export class Subscriber extends BasePeerConnection {
|
|
|
207
219
|
(p) => p.trackLookupPrefix === trackId,
|
|
208
220
|
);
|
|
209
221
|
if (!target) return;
|
|
222
|
+
if (target.isLocalParticipant) return;
|
|
210
223
|
this.state.updateParticipant(target.sessionId, (p) => {
|
|
211
224
|
const current = p.interruptedTracks ?? [];
|
|
212
225
|
const has = current.includes(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');
|
|
@@ -446,6 +447,65 @@ describe('Subscriber', () => {
|
|
|
446
447
|
});
|
|
447
448
|
|
|
448
449
|
describe('OnTrack', () => {
|
|
450
|
+
const audioStreamNamed = (trackLookupPrefix: string) => {
|
|
451
|
+
const stream = new MediaStream();
|
|
452
|
+
// @ts-expect-error - mock
|
|
453
|
+
stream.id = `${trackLookupPrefix}:TRACK_TYPE_AUDIO`;
|
|
454
|
+
return stream;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const audioTrack = () => {
|
|
458
|
+
const track = new MediaStreamTrack();
|
|
459
|
+
// @ts-expect-error - mock
|
|
460
|
+
track.kind = 'audio';
|
|
461
|
+
return track;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
it('replaces the local capture stream with the echoed one, leaving the capture tracks live', () => {
|
|
465
|
+
const captureTrack = audioTrack();
|
|
466
|
+
const captureStream = new MediaStream();
|
|
467
|
+
captureStream.getTracks = () => [captureTrack];
|
|
468
|
+
const echoedStream = audioStreamNamed('self-lookup');
|
|
469
|
+
// @ts-expect-error - incomplete mock
|
|
470
|
+
state.updateOrAddParticipant('self-session', {
|
|
471
|
+
sessionId: 'self-session',
|
|
472
|
+
trackLookupPrefix: 'self-lookup',
|
|
473
|
+
isLocalParticipant: true,
|
|
474
|
+
audioStream: captureStream,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
478
|
+
// @ts-expect-error - incomplete mock
|
|
479
|
+
onTrack({ streams: [echoedStream], track: audioTrack() });
|
|
480
|
+
|
|
481
|
+
const participant = state.participants.find(
|
|
482
|
+
(p) => p.sessionId === 'self-session',
|
|
483
|
+
);
|
|
484
|
+
expect(participant?.audioStream).toBe(echoedStream);
|
|
485
|
+
// the device manager owns the capture stream and is still publishing it
|
|
486
|
+
expect(captureTrack.stop).not.toHaveBeenCalled();
|
|
487
|
+
expect(captureStream.removeTrack).not.toHaveBeenCalled();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('reports streams it received as self-subscribed, and capture streams as not', () => {
|
|
491
|
+
const echoedStream = audioStreamNamed('self-lookup');
|
|
492
|
+
const captureStream = new MediaStream();
|
|
493
|
+
// @ts-expect-error - incomplete mock
|
|
494
|
+
state.updateOrAddParticipant('self-session', {
|
|
495
|
+
sessionId: 'self-session',
|
|
496
|
+
trackLookupPrefix: 'self-lookup',
|
|
497
|
+
isLocalParticipant: true,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
501
|
+
// @ts-expect-error - incomplete mock
|
|
502
|
+
onTrack({ streams: [echoedStream], track: audioTrack() });
|
|
503
|
+
|
|
504
|
+
expect(subscriber.isSelfSubscribedStream(echoedStream)).toBe(true);
|
|
505
|
+
expect(subscriber.isSelfSubscribedStream(captureStream)).toBe(false);
|
|
506
|
+
expect(subscriber.isSelfSubscribedStream(undefined)).toBe(false);
|
|
507
|
+
});
|
|
508
|
+
|
|
449
509
|
it('should add unknown tracks to the to the call state', () => {
|
|
450
510
|
const mediaStream = new MediaStream();
|
|
451
511
|
const mediaStreamTrack = new MediaStreamTrack();
|
|
@@ -522,6 +582,94 @@ describe('Subscriber', () => {
|
|
|
522
582
|
expect(baseTrack.stop).toHaveBeenCalled();
|
|
523
583
|
expect(baseStream.removeTrack).toHaveBeenCalledWith(baseTrack);
|
|
524
584
|
});
|
|
585
|
+
|
|
586
|
+
it('should store receiver in orphaned track when E2EE is enabled', () => {
|
|
587
|
+
const e2eeMock = {
|
|
588
|
+
encrypt: vi.fn(),
|
|
589
|
+
decrypt: vi.fn(),
|
|
590
|
+
};
|
|
591
|
+
subscriber.dispose();
|
|
592
|
+
const e2eeState = new CallState();
|
|
593
|
+
subscriber = new Subscriber({
|
|
594
|
+
sfuClient,
|
|
595
|
+
dispatcher,
|
|
596
|
+
state: e2eeState,
|
|
597
|
+
connectionConfig: { iceServers: [] },
|
|
598
|
+
tag: 'test',
|
|
599
|
+
enableTracing: true,
|
|
600
|
+
e2ee: e2eeMock,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const mediaStream = new MediaStream();
|
|
604
|
+
const mediaStreamTrack = new MediaStreamTrack();
|
|
605
|
+
const receiver = {};
|
|
606
|
+
// @ts-expect-error - mock
|
|
607
|
+
mediaStream.id = 'orphan-prefix:TRACK_TYPE_VIDEO';
|
|
608
|
+
|
|
609
|
+
const registerOrphanedTrackSpy = vi.spyOn(
|
|
610
|
+
e2eeState,
|
|
611
|
+
'registerOrphanedTrack',
|
|
612
|
+
);
|
|
613
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
614
|
+
// @ts-expect-error - incomplete mock
|
|
615
|
+
onTrack({ streams: [mediaStream], track: mediaStreamTrack, receiver });
|
|
616
|
+
|
|
617
|
+
// Decrypt is NOT called immediately for orphaned tracks
|
|
618
|
+
expect(e2eeMock.decrypt).not.toHaveBeenCalled();
|
|
619
|
+
// Receiver is stored with the orphaned track for later reconciliation
|
|
620
|
+
expect(registerOrphanedTrackSpy).toHaveBeenCalledWith({
|
|
621
|
+
id: mediaStream.id,
|
|
622
|
+
trackLookupPrefix: 'orphan-prefix',
|
|
623
|
+
track: mediaStream,
|
|
624
|
+
trackType: TrackType.VIDEO,
|
|
625
|
+
receiver,
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('should decrypt with userId when participant is found', () => {
|
|
630
|
+
const e2eeMock = {
|
|
631
|
+
encrypt: vi.fn(),
|
|
632
|
+
decrypt: vi.fn(),
|
|
633
|
+
};
|
|
634
|
+
subscriber.dispose();
|
|
635
|
+
const e2eeState = new CallState();
|
|
636
|
+
subscriber = new Subscriber({
|
|
637
|
+
sfuClient,
|
|
638
|
+
dispatcher,
|
|
639
|
+
state: e2eeState,
|
|
640
|
+
connectionConfig: { iceServers: [] },
|
|
641
|
+
tag: 'test',
|
|
642
|
+
enableTracing: true,
|
|
643
|
+
e2ee: e2eeMock,
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// Register a participant whose trackLookupPrefix matches the stream
|
|
647
|
+
e2eeState.updateOrAddParticipant(
|
|
648
|
+
'session-id',
|
|
649
|
+
fromPartial({
|
|
650
|
+
sessionId: 'session-id',
|
|
651
|
+
userId: 'real-user-id',
|
|
652
|
+
trackLookupPrefix: '123',
|
|
653
|
+
}),
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
const mediaStream = new MediaStream();
|
|
657
|
+
const mediaStreamTrack = new MediaStreamTrack();
|
|
658
|
+
const receiver = {};
|
|
659
|
+
// @ts-expect-error - mock
|
|
660
|
+
mediaStream.id = '123:TRACK_TYPE_VIDEO';
|
|
661
|
+
|
|
662
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
663
|
+
// @ts-expect-error - incomplete mock
|
|
664
|
+
onTrack({ streams: [mediaStream], track: mediaStreamTrack, receiver });
|
|
665
|
+
|
|
666
|
+
// Uses the participant's userId (not trackLookupPrefix) for key lookup
|
|
667
|
+
expect(e2eeMock.decrypt).toHaveBeenCalledWith(
|
|
668
|
+
receiver,
|
|
669
|
+
'real-user-id',
|
|
670
|
+
'VIDEO',
|
|
671
|
+
);
|
|
672
|
+
});
|
|
525
673
|
});
|
|
526
674
|
|
|
527
675
|
describe('interruptedTracks', () => {
|
|
@@ -594,6 +742,48 @@ describe('Subscriber', () => {
|
|
|
594
742
|
expect(interruptedFor('session-id')).toEqual([]);
|
|
595
743
|
});
|
|
596
744
|
|
|
745
|
+
it('leaves the local participant alone on a self-sub track', () => {
|
|
746
|
+
const mediaStream = new MediaStream();
|
|
747
|
+
const track = new MediaStreamTrack();
|
|
748
|
+
// @ts-expect-error - mock
|
|
749
|
+
mediaStream.id = 'self-lookup:TRACK_TYPE_AUDIO';
|
|
750
|
+
// @ts-expect-error - mock
|
|
751
|
+
track.kind = 'audio';
|
|
752
|
+
Object.defineProperty(track, 'muted', {
|
|
753
|
+
configurable: true,
|
|
754
|
+
get: () => true,
|
|
755
|
+
});
|
|
756
|
+
// @ts-expect-error - incomplete mock
|
|
757
|
+
state.updateOrAddParticipant('self-session', {
|
|
758
|
+
sessionId: 'self-session',
|
|
759
|
+
trackLookupPrefix: 'self-lookup',
|
|
760
|
+
isLocalParticipant: true,
|
|
761
|
+
interruptedTracks: [],
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
765
|
+
// @ts-expect-error - incomplete mock
|
|
766
|
+
onTrack({ streams: [mediaStream], track });
|
|
767
|
+
|
|
768
|
+
expect(interruptedFor('self-session')).toEqual([]);
|
|
769
|
+
|
|
770
|
+
const handlers: Record<string, () => void> = {};
|
|
771
|
+
for (const [event, handler] of (
|
|
772
|
+
track.addEventListener as ReturnType<typeof vi.fn>
|
|
773
|
+
).mock.calls) {
|
|
774
|
+
handlers[event] = handler as () => void;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
handlers['mute']();
|
|
778
|
+
expect(interruptedFor('self-session')).toEqual([]);
|
|
779
|
+
|
|
780
|
+
state.updateParticipant('self-session', {
|
|
781
|
+
interruptedTracks: [TrackType.AUDIO],
|
|
782
|
+
});
|
|
783
|
+
handlers['unmute']();
|
|
784
|
+
expect(interruptedFor('self-session')).toEqual([TrackType.AUDIO]);
|
|
785
|
+
});
|
|
786
|
+
|
|
597
787
|
it('ignores non-audio remote tracks to avoid Dynascale false positives', () => {
|
|
598
788
|
// Remote video track.muted is dominated by viewport-driven
|
|
599
789
|
// SFU unsubscriptions, so we deliberately only track audio
|