@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
package/src/Call.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { StreamSfuClient } from './StreamSfuClient';
|
|
|
2
2
|
import { SfuJoinError } from './errors';
|
|
3
3
|
import {
|
|
4
4
|
BasePeerConnectionOpts,
|
|
5
|
+
type CallMediaEngine,
|
|
5
6
|
Dispatcher,
|
|
7
|
+
getCallMediaEngineProvider,
|
|
6
8
|
getGenericSdp,
|
|
7
9
|
isAudioTrackType,
|
|
8
10
|
isSfuEvent,
|
|
@@ -15,6 +17,7 @@ import {
|
|
|
15
17
|
TrackPublishOptions,
|
|
16
18
|
trackTypeToParticipantStreamKey,
|
|
17
19
|
} from './rtc';
|
|
20
|
+
import type { E2EEManager } from './rtc/e2ee/E2EEManager';
|
|
18
21
|
import {
|
|
19
22
|
registerEventHandlers,
|
|
20
23
|
registerRingingCallEventHandlers,
|
|
@@ -30,7 +33,7 @@ import {
|
|
|
30
33
|
getCurrentValue,
|
|
31
34
|
} from './store/rxUtils';
|
|
32
35
|
import { ScopedLogger, videoLoggerSystem } from './logger';
|
|
33
|
-
import
|
|
36
|
+
import {
|
|
34
37
|
AcceptCallResponse,
|
|
35
38
|
BlockUserRequest,
|
|
36
39
|
BlockUserResponse,
|
|
@@ -76,8 +79,8 @@ import type {
|
|
|
76
79
|
RingCallResponse,
|
|
77
80
|
SendCallEventRequest,
|
|
78
81
|
SendCallEventResponse,
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
SendVideoReactionRequest,
|
|
83
|
+
SendVideoReactionResponse,
|
|
81
84
|
StartClosedCaptionsRequest,
|
|
82
85
|
StartClosedCaptionsResponse,
|
|
83
86
|
StartFrameRecordingRequest,
|
|
@@ -109,6 +112,8 @@ import type {
|
|
|
109
112
|
UpdateCallRequest,
|
|
110
113
|
UpdateCallResponse,
|
|
111
114
|
UpdateUserPermissionsRequest,
|
|
115
|
+
UpdateUserPermissionsRequestGrantPermissionsEnum,
|
|
116
|
+
UpdateUserPermissionsRequestRevokePermissionsEnum,
|
|
112
117
|
UpdateUserPermissionsResponse,
|
|
113
118
|
} from './gen/coordinator';
|
|
114
119
|
import { OwnCapability } from './gen/coordinator';
|
|
@@ -266,6 +271,7 @@ export class Call {
|
|
|
266
271
|
|
|
267
272
|
subscriber?: Subscriber;
|
|
268
273
|
publisher?: Publisher;
|
|
274
|
+
e2eeManager?: E2EEManager;
|
|
269
275
|
|
|
270
276
|
/**
|
|
271
277
|
* Flag telling whether this call is a "ringing" call.
|
|
@@ -328,6 +334,8 @@ export class Call {
|
|
|
328
334
|
private allowOwnTracksLoopback = false;
|
|
329
335
|
private hasJoinedOnce = false;
|
|
330
336
|
private deviceSettingsAppliedOnce = false;
|
|
337
|
+
private callManagerStarted = false;
|
|
338
|
+
private leaveGeneration = 0;
|
|
331
339
|
private credentials?: Credentials;
|
|
332
340
|
|
|
333
341
|
private initialized = false;
|
|
@@ -351,6 +359,14 @@ export class Call {
|
|
|
351
359
|
ClientCapability.SUBSCRIBER_VIDEO_PAUSE,
|
|
352
360
|
]);
|
|
353
361
|
|
|
362
|
+
/**
|
|
363
|
+
* The in-flight per-call media engine. On web/React this resolves to a thin
|
|
364
|
+
* globals-backed engine (no provider registered); React Native registers a
|
|
365
|
+
* provider that owns a per-call native factory.
|
|
366
|
+
* @internal
|
|
367
|
+
*/
|
|
368
|
+
private mediaEnginePromise?: Promise<CallMediaEngine>;
|
|
369
|
+
|
|
354
370
|
/**
|
|
355
371
|
* Constructs a new `Call` instance.
|
|
356
372
|
*
|
|
@@ -699,6 +715,8 @@ export class Call {
|
|
|
699
715
|
return;
|
|
700
716
|
}
|
|
701
717
|
|
|
718
|
+
this.leaveGeneration += 1;
|
|
719
|
+
|
|
702
720
|
if (callingState === CallingState.JOINING) {
|
|
703
721
|
const waitUntilCallJoined = () => {
|
|
704
722
|
return new Promise<void>((resolve) => {
|
|
@@ -799,6 +817,7 @@ export class Call {
|
|
|
799
817
|
|
|
800
818
|
globalThis.streamRNVideoSDK?.callManager.stop({
|
|
801
819
|
isRingingTypeCall: this.ringing,
|
|
820
|
+
shouldStopCallManager: this.callManagerStarted,
|
|
802
821
|
});
|
|
803
822
|
|
|
804
823
|
this.camera.dispose();
|
|
@@ -806,6 +825,7 @@ export class Call {
|
|
|
806
825
|
this.screenShare.dispose();
|
|
807
826
|
this.speaker.dispose();
|
|
808
827
|
this.deviceSettingsAppliedOnce = false;
|
|
828
|
+
this.callManagerStarted = false;
|
|
809
829
|
|
|
810
830
|
const stopOnLeavePromises: Promise<void>[] = [];
|
|
811
831
|
if (this.camera.stopOnLeave) {
|
|
@@ -818,6 +838,23 @@ export class Call {
|
|
|
818
838
|
stopOnLeavePromises.push(this.screenShare.disable(true));
|
|
819
839
|
}
|
|
820
840
|
await Promise.all(stopOnLeavePromises);
|
|
841
|
+
|
|
842
|
+
// Dispose the per-call media engine last — after peer connections and
|
|
843
|
+
// local tracks are gone — so the backing factory tears down with no
|
|
844
|
+
// owned PCs/tracks. A fresh `join()` builds a new engine.
|
|
845
|
+
if (this.mediaEnginePromise) {
|
|
846
|
+
const enginePromise = this.mediaEnginePromise;
|
|
847
|
+
this.mediaEnginePromise = undefined;
|
|
848
|
+
this.logger.debug('Disposing per-call media factory');
|
|
849
|
+
await enginePromise
|
|
850
|
+
.then((engine) => {
|
|
851
|
+
globalThis.streamRNVideoSDK?.callingX?.unwireAudioEngineSubscription();
|
|
852
|
+
return engine.dispose();
|
|
853
|
+
})
|
|
854
|
+
.catch((err) => {
|
|
855
|
+
this.logger.warn('Failed to dispose media engine', err);
|
|
856
|
+
});
|
|
857
|
+
}
|
|
821
858
|
});
|
|
822
859
|
};
|
|
823
860
|
|
|
@@ -937,11 +974,7 @@ export class Call {
|
|
|
937
974
|
this.clientStore.registerOrUpdateCall(this);
|
|
938
975
|
}
|
|
939
976
|
// Skip speaker setup on RN if ringing was requested or the call is already ringing
|
|
940
|
-
const skipSpeakerApply = isReactNative()
|
|
941
|
-
? params?.ring === true
|
|
942
|
-
? true
|
|
943
|
-
: this.ringing
|
|
944
|
-
: false;
|
|
977
|
+
const skipSpeakerApply = isReactNative();
|
|
945
978
|
await this.applyDeviceConfig(
|
|
946
979
|
response.call.settings,
|
|
947
980
|
false,
|
|
@@ -978,11 +1011,7 @@ export class Call {
|
|
|
978
1011
|
}
|
|
979
1012
|
|
|
980
1013
|
// Skip speaker setup on RN if ringing was requested or the call is already ringing
|
|
981
|
-
const skipSpeakerApply = isReactNative()
|
|
982
|
-
? data?.ring === true
|
|
983
|
-
? true
|
|
984
|
-
: this.ringing
|
|
985
|
-
: false;
|
|
1014
|
+
const skipSpeakerApply = isReactNative();
|
|
986
1015
|
await this.applyDeviceConfig(
|
|
987
1016
|
response.call.settings,
|
|
988
1017
|
false,
|
|
@@ -1190,12 +1219,26 @@ export class Call {
|
|
|
1190
1219
|
private doJoin = async (data?: JoinCallData): Promise<void> => {
|
|
1191
1220
|
const connectStartTime = Date.now();
|
|
1192
1221
|
const callingState = this.state.callingState;
|
|
1222
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
1223
|
+
const supersededByLeave = () =>
|
|
1224
|
+
this.leaveGeneration !== joinLeaveGeneration;
|
|
1193
1225
|
|
|
1194
1226
|
this.joinCallData = data;
|
|
1195
1227
|
|
|
1196
1228
|
this.logger.debug('Starting join flow');
|
|
1197
1229
|
this.state.setCallingState(CallingState.JOINING);
|
|
1198
1230
|
|
|
1231
|
+
// Ensure the per-call media engine exists before any peer connection
|
|
1232
|
+
// (codec probe, subscriber, publisher) or capture happens, so the WebRTC
|
|
1233
|
+
// globals resolve to the call's factory. Idempotent across
|
|
1234
|
+
// reconnect/migration attempts.
|
|
1235
|
+
await this.ensureMediaFactory();
|
|
1236
|
+
|
|
1237
|
+
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
1238
|
+
if (callingX) {
|
|
1239
|
+
callingX.wireAudioEngineSubscription();
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1199
1242
|
const performingMigration =
|
|
1200
1243
|
this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
1201
1244
|
const performingRejoin =
|
|
@@ -1266,6 +1309,11 @@ export class Call {
|
|
|
1266
1309
|
// the capabilities of the client (codec support, etc.)
|
|
1267
1310
|
const { dangerouslyForceCodec, fmtpLine, subscriberFmtpLine } =
|
|
1268
1311
|
this.clientPublishOptions || {};
|
|
1312
|
+
// skip if a leave superseded this join so codec detection doesn't resolve to a default factory.
|
|
1313
|
+
if (supersededByLeave()) {
|
|
1314
|
+
this.logger.debug('Join superseded by leave; skipping codec detection');
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1269
1317
|
const [subscriberSdp, publisherSdp] = await Promise.all([
|
|
1270
1318
|
getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
|
|
1271
1319
|
getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
|
|
@@ -1325,6 +1373,13 @@ export class Call {
|
|
|
1325
1373
|
}
|
|
1326
1374
|
}
|
|
1327
1375
|
|
|
1376
|
+
// If the user left while this join was in flight, bail before re-setting JOINED and before
|
|
1377
|
+
// peer-connection setup below (both run synchronously after this, so one check covers them).
|
|
1378
|
+
if (supersededByLeave()) {
|
|
1379
|
+
this.logger.debug('Join superseded by leave; aborting join flow');
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1328
1383
|
if (!performingMigration) {
|
|
1329
1384
|
// in MIGRATION, `JOINED` state is set in `this.reconnectMigrate()`
|
|
1330
1385
|
this.state.setCallingState(CallingState.JOINED);
|
|
@@ -1371,12 +1426,21 @@ export class Call {
|
|
|
1371
1426
|
|
|
1372
1427
|
// device settings should be applied only once, we don't have to
|
|
1373
1428
|
// re-apply them on later reconnections or server-side data fetches
|
|
1374
|
-
if (
|
|
1429
|
+
if (
|
|
1430
|
+
!this.deviceSettingsAppliedOnce &&
|
|
1431
|
+
this.state.settings &&
|
|
1432
|
+
!supersededByLeave()
|
|
1433
|
+
) {
|
|
1375
1434
|
await this.applyDeviceConfig(this.state.settings, true, false);
|
|
1435
|
+
this.deviceSettingsAppliedOnce = true;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (!this.callManagerStarted && !supersededByLeave()) {
|
|
1376
1439
|
globalThis.streamRNVideoSDK?.callManager.start({
|
|
1377
1440
|
isRingingTypeCall: this.ringing,
|
|
1441
|
+
cid: this.cid,
|
|
1378
1442
|
});
|
|
1379
|
-
this.
|
|
1443
|
+
this.callManagerStarted = true;
|
|
1380
1444
|
}
|
|
1381
1445
|
|
|
1382
1446
|
// We shouldn't persist the `ring` and `notify` state after joining the call
|
|
@@ -1542,6 +1606,7 @@ export class Call {
|
|
|
1542
1606
|
this.sfuStatsReporter = undefined;
|
|
1543
1607
|
if (closePreviousInstances && this.subscriber) {
|
|
1544
1608
|
await this.subscriber.dispose();
|
|
1609
|
+
this.state.removeAllOrphanedTracks();
|
|
1545
1610
|
}
|
|
1546
1611
|
const basePeerConnectionOptions: BasePeerConnectionOpts = {
|
|
1547
1612
|
sfuClient,
|
|
@@ -1552,6 +1617,7 @@ export class Call {
|
|
|
1552
1617
|
enableTracing,
|
|
1553
1618
|
statsTimestampDriftThresholdMs: reportingIntervalMs / 2,
|
|
1554
1619
|
clientPublishOptions: this.clientPublishOptions,
|
|
1620
|
+
e2ee: this.e2eeManager,
|
|
1555
1621
|
onReconnectionNeeded: (kind, reason, peerType) => {
|
|
1556
1622
|
this.reconnect(kind, reason).catch((err) => {
|
|
1557
1623
|
const message = `[Reconnect] Error reconnecting, after a ${PeerType[peerType]} error: ${reason}`;
|
|
@@ -1628,12 +1694,12 @@ export class Call {
|
|
|
1628
1694
|
* Retrieves credentials for joining the call.
|
|
1629
1695
|
*
|
|
1630
1696
|
* @internal
|
|
1631
|
-
*
|
|
1632
1697
|
* @param data the join call data.
|
|
1633
1698
|
*/
|
|
1634
1699
|
doJoinRequest = async (data?: JoinCallData): Promise<JoinCallResponse> => {
|
|
1635
1700
|
const location = await this.streamClient.getLocationHint();
|
|
1636
|
-
const
|
|
1701
|
+
const e2ee = !!this.e2eeManager;
|
|
1702
|
+
const request: JoinCallRequest = { ...data, location, e2ee };
|
|
1637
1703
|
const joinResponse = await this.streamClient.post<
|
|
1638
1704
|
JoinCallResponse,
|
|
1639
1705
|
JoinCallRequest
|
|
@@ -1663,6 +1729,39 @@ export class Call {
|
|
|
1663
1729
|
return joinResponse;
|
|
1664
1730
|
};
|
|
1665
1731
|
|
|
1732
|
+
/**
|
|
1733
|
+
* Whether the per-call media engine currently exists. True from join until leave.
|
|
1734
|
+
*
|
|
1735
|
+
* @internal an internal getter and should not be used outside the SDK.
|
|
1736
|
+
*/
|
|
1737
|
+
get hasMediaEngine(): boolean {
|
|
1738
|
+
return !!this.mediaEnginePromise;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/**
|
|
1742
|
+
* Ensures a {@link CallMediaEngine} exists for this call's media session and
|
|
1743
|
+
* returns it. Idempotent: the engine is created once via the registered
|
|
1744
|
+
* provider (see `setCallMediaEngineProvider`) and cached until `leave()`
|
|
1745
|
+
* disposes it. Concurrent callers (e.g. camera + microphone enabling in
|
|
1746
|
+
* parallel) share the same engine because the in-flight creation promise is
|
|
1747
|
+
* cached, never the unresolved result.
|
|
1748
|
+
*
|
|
1749
|
+
* @internal
|
|
1750
|
+
*/
|
|
1751
|
+
ensureMediaFactory = async (): Promise<CallMediaEngine> => {
|
|
1752
|
+
if (!this.mediaEnginePromise) {
|
|
1753
|
+
const provider = getCallMediaEngineProvider();
|
|
1754
|
+
|
|
1755
|
+
this.logger.debug(`Requesting per-call media factory creation`);
|
|
1756
|
+
this.mediaEnginePromise = Promise.resolve(provider()).catch((err) => {
|
|
1757
|
+
// Drop the cached rejection so a retried join() can rebuild the engine
|
|
1758
|
+
this.mediaEnginePromise = undefined;
|
|
1759
|
+
throw err;
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
return this.mediaEnginePromise;
|
|
1763
|
+
};
|
|
1764
|
+
|
|
1666
1765
|
/**
|
|
1667
1766
|
* Handles the closing of the SFU signal connection.
|
|
1668
1767
|
*
|
|
@@ -2355,6 +2454,31 @@ export class Call {
|
|
|
2355
2454
|
this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
|
|
2356
2455
|
};
|
|
2357
2456
|
|
|
2457
|
+
/**
|
|
2458
|
+
* Set the E2EE (end-to-end encryption) manager for this call.
|
|
2459
|
+
*
|
|
2460
|
+
* Must be called before {@link join} so the RTCPeerConnection can be
|
|
2461
|
+
* configured for E2EE.
|
|
2462
|
+
*
|
|
2463
|
+
* The manager is kept across {@link leave} so a rejoin of this same instance
|
|
2464
|
+
* stays encrypted: do not dispose it while this call may be joined again.
|
|
2465
|
+
* A disposed manager throws from `encrypt`/`decrypt` rather than silently
|
|
2466
|
+
* publishing nothing, so re-attach a fresh one instead of reusing it.
|
|
2467
|
+
*
|
|
2468
|
+
* @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
|
|
2469
|
+
* built-in AES-GCM scheme, or pass your own implementation.
|
|
2470
|
+
* @throws if called after the peer connections have been built (i.e. after
|
|
2471
|
+
* `join`): those PCs were already configured without an encryptor, so
|
|
2472
|
+
* adopting a manager now would silently publish/receive cleartext for
|
|
2473
|
+
* the live session.
|
|
2474
|
+
*/
|
|
2475
|
+
setE2EEManager = (e2ee: E2EEManager) => {
|
|
2476
|
+
if (this.publisher || this.subscriber) {
|
|
2477
|
+
throw new Error('setE2EEManager must be called before join()');
|
|
2478
|
+
}
|
|
2479
|
+
this.e2eeManager = e2ee;
|
|
2480
|
+
};
|
|
2481
|
+
|
|
2358
2482
|
/**
|
|
2359
2483
|
* Notifies the SFU that a noise cancellation process has started.
|
|
2360
2484
|
*
|
|
@@ -2453,9 +2577,9 @@ export class Call {
|
|
|
2453
2577
|
* @param reaction the reaction to send.
|
|
2454
2578
|
*/
|
|
2455
2579
|
sendReaction = async (
|
|
2456
|
-
reaction:
|
|
2457
|
-
): Promise<
|
|
2458
|
-
return this.streamClient.post
|
|
2580
|
+
reaction: SendVideoReactionRequest,
|
|
2581
|
+
): Promise<SendVideoReactionResponse> => {
|
|
2582
|
+
return this.streamClient.post(
|
|
2459
2583
|
`${this.streamClientBasePath}/reaction`,
|
|
2460
2584
|
reaction,
|
|
2461
2585
|
);
|
|
@@ -2673,7 +2797,7 @@ export class Call {
|
|
|
2673
2797
|
): Promise<RequestPermissionResponse> => {
|
|
2674
2798
|
const { permissions } = data;
|
|
2675
2799
|
const canRequestPermissions = permissions.every((permission) =>
|
|
2676
|
-
this.permissionsContext.canRequest(permission
|
|
2800
|
+
this.permissionsContext.canRequest(permission),
|
|
2677
2801
|
);
|
|
2678
2802
|
if (!canRequestPermissions) {
|
|
2679
2803
|
throw new Error(
|
|
@@ -2698,10 +2822,14 @@ export class Call {
|
|
|
2698
2822
|
* @param userId the id of the user to grant permissions to.
|
|
2699
2823
|
* @param permissions the permissions to grant.
|
|
2700
2824
|
*/
|
|
2701
|
-
grantPermissions = async (
|
|
2825
|
+
grantPermissions = async (
|
|
2826
|
+
userId: string,
|
|
2827
|
+
permissions: string[] | UpdateUserPermissionsRequestGrantPermissionsEnum[],
|
|
2828
|
+
) => {
|
|
2702
2829
|
return this.updateUserPermissions({
|
|
2703
2830
|
user_id: userId,
|
|
2704
|
-
grant_permissions:
|
|
2831
|
+
grant_permissions:
|
|
2832
|
+
permissions as UpdateUserPermissionsRequestGrantPermissionsEnum[],
|
|
2705
2833
|
});
|
|
2706
2834
|
};
|
|
2707
2835
|
|
|
@@ -2717,10 +2845,14 @@ export class Call {
|
|
|
2717
2845
|
* @param userId the id of the user to revoke permissions from.
|
|
2718
2846
|
* @param permissions the permissions to revoke.
|
|
2719
2847
|
*/
|
|
2720
|
-
revokePermissions = async (
|
|
2848
|
+
revokePermissions = async (
|
|
2849
|
+
userId: string,
|
|
2850
|
+
permissions: string[] | UpdateUserPermissionsRequestRevokePermissionsEnum[],
|
|
2851
|
+
) => {
|
|
2721
2852
|
return this.updateUserPermissions({
|
|
2722
2853
|
user_id: userId,
|
|
2723
|
-
revoke_permissions:
|
|
2854
|
+
revoke_permissions:
|
|
2855
|
+
permissions as UpdateUserPermissionsRequestRevokePermissionsEnum[],
|
|
2724
2856
|
});
|
|
2725
2857
|
};
|
|
2726
2858
|
|
|
@@ -3239,7 +3371,9 @@ export class Call {
|
|
|
3239
3371
|
skipSpeakerApply: boolean,
|
|
3240
3372
|
) => {
|
|
3241
3373
|
if (!skipSpeakerApply) {
|
|
3242
|
-
this.speaker.apply(settings)
|
|
3374
|
+
await this.speaker.apply(settings).catch((err) => {
|
|
3375
|
+
this.logger.warn('Speaker init failed', err);
|
|
3376
|
+
});
|
|
3243
3377
|
}
|
|
3244
3378
|
await this.camera.apply(settings.video, publish).catch((err) => {
|
|
3245
3379
|
this.logger.warn('Camera init failed', err);
|
|
@@ -17,6 +17,7 @@ import { Dispatcher } from '../rtc';
|
|
|
17
17
|
import { Call } from '../Call';
|
|
18
18
|
import { StreamVideoParticipant } from '../types';
|
|
19
19
|
import { TrackType } from '../gen/video/sfu/models/models';
|
|
20
|
+
import type { E2EEManager } from '../rtc/e2ee/E2EEManager';
|
|
20
21
|
|
|
21
22
|
const apiKey = process.env.STREAM_API_KEY!;
|
|
22
23
|
const secret = process.env.STREAM_SECRET!;
|
|
@@ -300,6 +301,20 @@ describe('muting logic', () => {
|
|
|
300
301
|
});
|
|
301
302
|
});
|
|
302
303
|
|
|
304
|
+
describe('setE2EEManager', () => {
|
|
305
|
+
it('throws when called after the peer connections exist', () => {
|
|
306
|
+
const call = client.call('default', generateUUIDv4());
|
|
307
|
+
const e2ee = fromPartial<E2EEManager>({});
|
|
308
|
+
// Before join there are no peer connections, so it is accepted.
|
|
309
|
+
expect(() => call.setE2EEManager(e2ee)).not.toThrow();
|
|
310
|
+
// Once a subscriber/publisher exists (i.e. after join) the PCs were already
|
|
311
|
+
// built without the manager; setting it now would leave the live session
|
|
312
|
+
// half-encrypted, so it must be rejected (finding 12).
|
|
313
|
+
call['subscriber'] = fromPartial({});
|
|
314
|
+
expect(() => call.setE2EEManager(e2ee)).toThrow(/before join/);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
303
318
|
describe('client event reporting', () => {
|
|
304
319
|
afterEach(() => {
|
|
305
320
|
vi.restoreAllMocks();
|
|
@@ -9,10 +9,11 @@ import {
|
|
|
9
9
|
} from './utils';
|
|
10
10
|
import type { StreamVideoEvent, UR, WSConnectionError } from './types';
|
|
11
11
|
import type { LogLevel } from '@stream-io/logger';
|
|
12
|
-
import
|
|
12
|
+
import {
|
|
13
13
|
ConnectedEvent,
|
|
14
14
|
ConnectionErrorEvent,
|
|
15
15
|
WSAuthMessage,
|
|
16
|
+
WSAuthMessageProductsEnum,
|
|
16
17
|
} from '../../gen/coordinator';
|
|
17
18
|
import { makeSafePromise, type SafePromise } from '../../helpers/promise';
|
|
18
19
|
import { getTimers } from '../../timers';
|
|
@@ -529,15 +530,17 @@ export class StableWSConnection {
|
|
|
529
530
|
return;
|
|
530
531
|
}
|
|
531
532
|
|
|
532
|
-
const
|
|
533
|
+
const wsAuthMessage: WSAuthMessage = {
|
|
533
534
|
token,
|
|
535
|
+
products: [WSAuthMessageProductsEnum.VIDEO],
|
|
534
536
|
user_details: {
|
|
535
537
|
id: user.id,
|
|
536
538
|
name: user.name,
|
|
537
539
|
image: user.image,
|
|
538
540
|
custom: user.custom,
|
|
539
541
|
},
|
|
540
|
-
}
|
|
542
|
+
};
|
|
543
|
+
const authMessage = JSON.stringify(wsAuthMessage);
|
|
541
544
|
|
|
542
545
|
this._log(`onopen() - Sending auth message ${authMessage}`, {}, 'trace');
|
|
543
546
|
|
|
@@ -2,6 +2,7 @@ import { DeviceManager } from './DeviceManager';
|
|
|
2
2
|
import { AudioDeviceManagerState } from './AudioDeviceManagerState';
|
|
3
3
|
import { AudioBitrateProfile } from '../gen/video/sfu/models/models';
|
|
4
4
|
import { TrackPublishOptions } from '../rtc';
|
|
5
|
+
import { isReactNative } from '../helpers/platforms';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Base class for High Fidelity enabled Device Managers.
|
|
@@ -17,6 +18,11 @@ export abstract class AudioDeviceManager<
|
|
|
17
18
|
if (!this.call.state.settings?.audio.hifi_audio_enabled) {
|
|
18
19
|
throw new Error('High Fidelity audio is not enabled for this call');
|
|
19
20
|
}
|
|
21
|
+
if (isReactNative() && this.call.hasMediaEngine) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'setAudioBitrateProfile must be called before joining the call.',
|
|
24
|
+
);
|
|
25
|
+
}
|
|
20
26
|
this.doSetAudioBitrateProfile(profile);
|
|
21
27
|
this.state.setAudioBitrateProfile(profile);
|
|
22
28
|
if (this.enabled) {
|
|
@@ -124,6 +124,44 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
override enable(): Promise<void> {
|
|
128
|
+
if (isReactNative() && !this.call.hasMediaEngine) {
|
|
129
|
+
this.state.setPendingStatus('enabled');
|
|
130
|
+
return Promise.resolve();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return super.enable();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
override disable(options: { forceStop?: boolean }): Promise<void>;
|
|
137
|
+
override disable(forceStop?: boolean): Promise<void>;
|
|
138
|
+
override async disable(
|
|
139
|
+
forceStopOrOptions?: boolean | { forceStop?: boolean },
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
if (isReactNative() && !this.call.hasMediaEngine) {
|
|
142
|
+
this.state.setPendingStatus('disabled');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// forward verbatim to the base, narrowing so the right overload is selected
|
|
147
|
+
if (forceStopOrOptions === undefined) return super.disable();
|
|
148
|
+
if (typeof forceStopOrOptions === 'boolean') {
|
|
149
|
+
return super.disable(forceStopOrOptions);
|
|
150
|
+
}
|
|
151
|
+
return super.disable(forceStopOrOptions);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
override toggle(): Promise<void> {
|
|
155
|
+
if (isReactNative() && !this.call.hasMediaEngine) {
|
|
156
|
+
this.state.setPendingStatus(
|
|
157
|
+
this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled',
|
|
158
|
+
);
|
|
159
|
+
return Promise.resolve();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return super.toggle();
|
|
163
|
+
}
|
|
164
|
+
|
|
127
165
|
/**
|
|
128
166
|
* Applies the video settings to the camera.
|
|
129
167
|
*
|
|
@@ -166,9 +204,15 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
166
204
|
}
|
|
167
205
|
}
|
|
168
206
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
207
|
+
if (isReactNative() && publish && canPublish) {
|
|
208
|
+
// On RN the camera is enabled/disabled optimistically before JOINED. Reconcile now
|
|
209
|
+
// acquires the track and publishes it, so it fully owns the publish.
|
|
210
|
+
await this.reconcileOptimisticStatus();
|
|
211
|
+
} else {
|
|
212
|
+
const { mediaStream } = this.state;
|
|
213
|
+
if (canPublish && publish && this.enabled && mediaStream) {
|
|
214
|
+
await this.publishStream(mediaStream);
|
|
215
|
+
}
|
|
172
216
|
}
|
|
173
217
|
}
|
|
174
218
|
|
|
@@ -196,9 +240,12 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
196
240
|
return constraints;
|
|
197
241
|
}
|
|
198
242
|
|
|
199
|
-
protected override getStream(
|
|
243
|
+
protected override async getStream(
|
|
200
244
|
constraints: MediaTrackConstraints,
|
|
201
245
|
): Promise<MediaStream> {
|
|
246
|
+
// Ensure the call's media factory exists before capture so the resulting
|
|
247
|
+
// track is owned by it (the WebRTC globals resolve to the live factory).
|
|
248
|
+
await this.call.ensureMediaFactory();
|
|
202
249
|
return getVideoStream(constraints, this.call.tracer);
|
|
203
250
|
}
|
|
204
251
|
}
|
|
@@ -509,6 +509,23 @@ export abstract class DeviceManager<
|
|
|
509
509
|
}
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
+
protected reconcileOptimisticStatus = async (): Promise<void> => {
|
|
513
|
+
const target = this.state.optimisticStatus;
|
|
514
|
+
await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
|
|
515
|
+
try {
|
|
516
|
+
if (target === 'enabled' && this.state.status !== 'enabled') {
|
|
517
|
+
await this.unmuteStream();
|
|
518
|
+
if (!signal.aborted) this.state.setStatus('enabled');
|
|
519
|
+
} else if (target === 'disabled' && this.state.status === 'enabled') {
|
|
520
|
+
// mirror whatever disable() does to stop/pause the track per disableMode
|
|
521
|
+
if (!signal.aborted) this.state.setStatus('disabled');
|
|
522
|
+
}
|
|
523
|
+
} finally {
|
|
524
|
+
if (!signal.aborted) this.state.setPendingStatus(this.state.status);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
};
|
|
528
|
+
|
|
512
529
|
private disableTracks() {
|
|
513
530
|
this.getTracks().forEach((track) => {
|
|
514
531
|
if (track.enabled) track.enabled = false;
|
|
@@ -76,7 +76,10 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
76
76
|
]) => {
|
|
77
77
|
try {
|
|
78
78
|
if (callingState === CallingState.LEFT) {
|
|
79
|
-
|
|
79
|
+
// The muted-recording-prepared mode is reset in `callManager.stop()`
|
|
80
|
+
// (during leave, while the call factory is still alive), not here —
|
|
81
|
+
// this subscription fires asynchronously and could land after the
|
|
82
|
+
// factory is disposed, forcing a default-ADM rebuild.
|
|
80
83
|
await this.stopSpeakingWhileMutedDetection();
|
|
81
84
|
}
|
|
82
85
|
if (callingState !== CallingState.JOINED) return;
|
|
@@ -187,6 +190,44 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
187
190
|
}
|
|
188
191
|
}
|
|
189
192
|
|
|
193
|
+
override enable(): Promise<void> {
|
|
194
|
+
if (isReactNative() && !this.call.hasMediaEngine) {
|
|
195
|
+
this.state.setPendingStatus('enabled');
|
|
196
|
+
return Promise.resolve();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return super.enable();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
override disable(options: { forceStop?: boolean }): Promise<void>;
|
|
203
|
+
override disable(forceStop?: boolean): Promise<void>;
|
|
204
|
+
override async disable(
|
|
205
|
+
forceStopOrOptions?: boolean | { forceStop?: boolean },
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
if (isReactNative() && !this.call.hasMediaEngine) {
|
|
208
|
+
this.state.setPendingStatus('disabled');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// forward verbatim to the base, narrowing so the right overload is selected
|
|
213
|
+
if (forceStopOrOptions === undefined) return super.disable();
|
|
214
|
+
if (typeof forceStopOrOptions === 'boolean') {
|
|
215
|
+
return super.disable(forceStopOrOptions);
|
|
216
|
+
}
|
|
217
|
+
return super.disable(forceStopOrOptions);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
override toggle(): Promise<void> {
|
|
221
|
+
if (isReactNative() && !this.call.hasMediaEngine) {
|
|
222
|
+
this.state.setPendingStatus(
|
|
223
|
+
this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled',
|
|
224
|
+
);
|
|
225
|
+
return Promise.resolve();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return super.toggle();
|
|
229
|
+
}
|
|
230
|
+
|
|
190
231
|
/**
|
|
191
232
|
* Enables noise cancellation for the microphone.
|
|
192
233
|
*
|
|
@@ -376,9 +417,15 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
376
417
|
}
|
|
377
418
|
}
|
|
378
419
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
420
|
+
if (isReactNative() && publish && canPublish) {
|
|
421
|
+
// On RN the microphone is enabled/disabled optimistically before JOINED. Reconcile now
|
|
422
|
+
// acquires the track and publishes it, so it fully owns the publish.
|
|
423
|
+
await this.reconcileOptimisticStatus();
|
|
424
|
+
} else {
|
|
425
|
+
const { mediaStream } = this.state;
|
|
426
|
+
if (canPublish && publish && this.enabled && mediaStream) {
|
|
427
|
+
await this.publishStream(mediaStream);
|
|
428
|
+
}
|
|
382
429
|
}
|
|
383
430
|
}
|
|
384
431
|
|
|
@@ -386,9 +433,12 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
386
433
|
return getAudioDevices(this.call.tracer);
|
|
387
434
|
}
|
|
388
435
|
|
|
389
|
-
protected override getStream(
|
|
436
|
+
protected override async getStream(
|
|
390
437
|
constraints: MediaTrackConstraints,
|
|
391
438
|
): Promise<MediaStream> {
|
|
439
|
+
// Ensure the call's media factory exists before capture so the resulting
|
|
440
|
+
// track is owned by it (the WebRTC globals resolve to the live factory).
|
|
441
|
+
await this.call.ensureMediaFactory();
|
|
392
442
|
return getAudioStream(constraints, this.call.tracer);
|
|
393
443
|
}
|
|
394
444
|
|
|
@@ -90,6 +90,9 @@ export class ScreenShareManager extends AudioDeviceManager<
|
|
|
90
90
|
if (!this.state.audioEnabled) {
|
|
91
91
|
constraints.audio = false;
|
|
92
92
|
}
|
|
93
|
+
// Ensure the call's media factory exists before capture so the resulting
|
|
94
|
+
// track is owned by it (the WebRTC globals resolve to the live factory).
|
|
95
|
+
await this.call.ensureMediaFactory();
|
|
93
96
|
const stream = await getScreenShareStream(constraints, this.call.tracer);
|
|
94
97
|
const [track] = stream.getVideoTracks();
|
|
95
98
|
const { contentHint } = this.state.settings || {};
|