@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { combineLatest } from 'rxjs';
|
|
1
|
+
import { combineLatest, firstValueFrom, pairwise } from 'rxjs';
|
|
2
2
|
import { Call } from '../Call';
|
|
3
3
|
import { isReactNative } from '../helpers/platforms';
|
|
4
4
|
import { SpeakerState } from './SpeakerState';
|
|
@@ -39,11 +39,15 @@ export class SpeakerManager {
|
|
|
39
39
|
this.setup();
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
apply(settings: CallSettingsResponse) {
|
|
43
|
-
|
|
42
|
+
async apply(settings: CallSettingsResponse): Promise<void> {
|
|
43
|
+
if (isReactNative()) {
|
|
44
|
+
this.applyRN(settings);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await this.applyWeb();
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
private applyWeb() {
|
|
50
|
+
private async applyWeb() {
|
|
47
51
|
const { enabled, storageKey } = this.devicePersistence;
|
|
48
52
|
if (!enabled) return;
|
|
49
53
|
|
|
@@ -56,8 +60,26 @@ export class SpeakerManager {
|
|
|
56
60
|
preference.selectedDeviceId === defaultDeviceId
|
|
57
61
|
? ''
|
|
58
62
|
: preference.selectedDeviceId;
|
|
59
|
-
if (
|
|
60
|
-
this.
|
|
63
|
+
if (!nextDeviceId) {
|
|
64
|
+
if (this.state.selectedDevice !== nextDeviceId) {
|
|
65
|
+
this.select(nextDeviceId);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const permissionState = await firstValueFrom(
|
|
71
|
+
getAudioBrowserPermission(this.call.tracer).asStateObservable(),
|
|
72
|
+
);
|
|
73
|
+
if (permissionState !== 'granted') return;
|
|
74
|
+
|
|
75
|
+
const devices = await firstValueFrom(this.listDevices());
|
|
76
|
+
const device =
|
|
77
|
+
this.findDevice(devices, nextDeviceId) ??
|
|
78
|
+
(preference.selectedDeviceLabel
|
|
79
|
+
? devices.find((d) => d.label === preference.selectedDeviceLabel)
|
|
80
|
+
: undefined);
|
|
81
|
+
if (device && this.state.selectedDevice !== device.deviceId) {
|
|
82
|
+
this.select(device.deviceId);
|
|
61
83
|
}
|
|
62
84
|
}
|
|
63
85
|
|
|
@@ -90,6 +112,7 @@ export class SpeakerManager {
|
|
|
90
112
|
globalThis.streamRNVideoSDK?.callManager.setup({
|
|
91
113
|
defaultDevice,
|
|
92
114
|
isRingingTypeCall: this.call.ringing,
|
|
115
|
+
cid: this.call.cid,
|
|
93
116
|
});
|
|
94
117
|
}
|
|
95
118
|
}
|
|
@@ -101,13 +124,16 @@ export class SpeakerManager {
|
|
|
101
124
|
if (deviceIds$ && !isReactNative()) {
|
|
102
125
|
this.subscriptions.push(
|
|
103
126
|
createSubscription(
|
|
104
|
-
combineLatest([
|
|
105
|
-
|
|
127
|
+
combineLatest([
|
|
128
|
+
deviceIds$!.pipe(pairwise()),
|
|
129
|
+
this.state.selectedDevice$,
|
|
130
|
+
]),
|
|
131
|
+
([[prevDevices, currentDevices], deviceId]) => {
|
|
106
132
|
if (!deviceId) return;
|
|
107
|
-
const
|
|
108
|
-
(
|
|
109
|
-
|
|
110
|
-
if (
|
|
133
|
+
const isDisconnected =
|
|
134
|
+
this.findDevice(prevDevices, deviceId) &&
|
|
135
|
+
!this.findDevice(currentDevices, deviceId);
|
|
136
|
+
if (isDisconnected) this.select('');
|
|
111
137
|
},
|
|
112
138
|
),
|
|
113
139
|
);
|
|
@@ -164,6 +190,7 @@ export class SpeakerManager {
|
|
|
164
190
|
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
165
191
|
this.subscriptions = [];
|
|
166
192
|
this.areSubscriptionsSetUp = false;
|
|
193
|
+
this.defaultDevice = undefined;
|
|
167
194
|
};
|
|
168
195
|
|
|
169
196
|
/**
|
|
@@ -201,6 +228,9 @@ export class SpeakerManager {
|
|
|
201
228
|
});
|
|
202
229
|
}
|
|
203
230
|
|
|
231
|
+
private findDevice = (devices: MediaDeviceInfo[], deviceId: string) =>
|
|
232
|
+
devices.find((d) => d.deviceId === deviceId && d.kind === 'audiooutput');
|
|
233
|
+
|
|
204
234
|
private persistSpeakerDevicePreference(selectedDevice: string) {
|
|
205
235
|
const { storageKey } = this.devicePersistence;
|
|
206
236
|
const devices = getCurrentValue(this.listDevices()) || [];
|
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
import { MicrophoneManager } from '../MicrophoneManager';
|
|
3
3
|
import { Call } from '../../Call';
|
|
4
4
|
import { StreamClient } from '../../coordinator/connection/client';
|
|
5
|
-
import {
|
|
5
|
+
import { StreamVideoWriteableStateStore } from '../../store';
|
|
6
6
|
import {
|
|
7
7
|
mockAudioDevices,
|
|
8
8
|
mockAudioStream,
|
|
@@ -263,25 +263,6 @@ describe('MicrophoneManager React Native', () => {
|
|
|
263
263
|
expect(setMutedRecordingPreparedMock).not.toHaveBeenCalledWith(true);
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
-
it('should release prepared muted recording when the call is left', async () => {
|
|
267
|
-
await manager.disable();
|
|
268
|
-
await vi.waitUntil(
|
|
269
|
-
() =>
|
|
270
|
-
setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === true),
|
|
271
|
-
{ timeout: 100 },
|
|
272
|
-
);
|
|
273
|
-
setMutedRecordingPreparedMock.mockClear();
|
|
274
|
-
|
|
275
|
-
manager['call'].state.setCallingState(CallingState.LEFT);
|
|
276
|
-
|
|
277
|
-
await vi.waitUntil(
|
|
278
|
-
() =>
|
|
279
|
-
setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === false),
|
|
280
|
-
{ timeout: 100 },
|
|
281
|
-
);
|
|
282
|
-
expect(setMutedRecordingPreparedMock).toHaveBeenCalledWith(false);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
266
|
afterEach(() => {
|
|
286
267
|
globalThis.streamRNVideoSDK = undefined;
|
|
287
268
|
vi.clearAllMocks();
|
|
@@ -6,8 +6,10 @@ import {
|
|
|
6
6
|
emitDeviceIds,
|
|
7
7
|
LocalStorageMock,
|
|
8
8
|
mockAudioDevices,
|
|
9
|
+
mockAudioOutputDevices,
|
|
9
10
|
mockBrowserPermission,
|
|
10
11
|
mockDeviceIds$,
|
|
12
|
+
mockDevicesWithoutAudioPermission,
|
|
11
13
|
} from './mocks';
|
|
12
14
|
import { of } from 'rxjs';
|
|
13
15
|
import { SpeakerManager } from '../SpeakerManager';
|
|
@@ -122,15 +124,36 @@ describe('SpeakerManager.test', () => {
|
|
|
122
124
|
});
|
|
123
125
|
|
|
124
126
|
it('should disable device if selected device is disconnected', () => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
+
const deviceId = mockAudioOutputDevices[0].deviceId;
|
|
128
|
+
emitDeviceIds([...mockAudioDevices, ...mockAudioOutputDevices]);
|
|
127
129
|
manager.select(deviceId);
|
|
128
130
|
|
|
129
|
-
emitDeviceIds(mockAudioDevices.slice(
|
|
131
|
+
emitDeviceIds([...mockAudioDevices, ...mockAudioOutputDevices.slice(1)]);
|
|
130
132
|
|
|
131
133
|
expect(manager.state.selectedDevice).toBe('');
|
|
132
134
|
});
|
|
133
135
|
|
|
136
|
+
it('should keep the selection when the device list never exposed audio ids', () => {
|
|
137
|
+
const deviceId = mockAudioOutputDevices[0].deviceId;
|
|
138
|
+
emitDeviceIds(mockDevicesWithoutAudioPermission);
|
|
139
|
+
|
|
140
|
+
manager.select(deviceId);
|
|
141
|
+
expect(manager.state.selectedDevice).toBe(deviceId);
|
|
142
|
+
|
|
143
|
+
emitDeviceIds(mockDevicesWithoutAudioPermission);
|
|
144
|
+
expect(manager.state.selectedDevice).toBe(deviceId);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should keep the selection when the device appears in a later enumeration', () => {
|
|
148
|
+
const deviceId = mockAudioOutputDevices[0].deviceId;
|
|
149
|
+
emitDeviceIds(mockDevicesWithoutAudioPermission);
|
|
150
|
+
manager.select(deviceId);
|
|
151
|
+
|
|
152
|
+
emitDeviceIds([...mockAudioDevices, ...mockAudioOutputDevices]);
|
|
153
|
+
|
|
154
|
+
expect(manager.state.selectedDevice).toBe(deviceId);
|
|
155
|
+
});
|
|
156
|
+
|
|
134
157
|
it('persists speaker selection when permission is granted', async () => {
|
|
135
158
|
const streamClient = new StreamClient('abc123');
|
|
136
159
|
const persistedManager = new SpeakerManager(
|
|
@@ -159,16 +182,9 @@ describe('SpeakerManager.test', () => {
|
|
|
159
182
|
});
|
|
160
183
|
|
|
161
184
|
describe('apply (web)', () => {
|
|
162
|
-
|
|
163
|
-
const selectSpy = vi.spyOn(manager, 'select');
|
|
164
|
-
// @ts-expect-error - partial data
|
|
165
|
-
manager.apply({});
|
|
166
|
-
expect(selectSpy).not.toHaveBeenCalled();
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('selects the persisted speaker device', () => {
|
|
185
|
+
const createPersistedManager = () => {
|
|
170
186
|
const streamClient = new StreamClient('abc123');
|
|
171
|
-
|
|
187
|
+
return new SpeakerManager(
|
|
172
188
|
new Call({
|
|
173
189
|
id: '',
|
|
174
190
|
type: '',
|
|
@@ -178,56 +194,136 @@ describe('SpeakerManager.test', () => {
|
|
|
178
194
|
}),
|
|
179
195
|
{ enabled: true, storageKey },
|
|
180
196
|
);
|
|
197
|
+
};
|
|
181
198
|
|
|
199
|
+
const persist = (selectedDeviceId: string, selectedDeviceLabel: string) => {
|
|
182
200
|
localStorageMock.setItem(
|
|
183
201
|
storageKey,
|
|
184
202
|
JSON.stringify({
|
|
185
|
-
speaker: [
|
|
186
|
-
{
|
|
187
|
-
selectedDeviceId: 'speaker-1',
|
|
188
|
-
selectedDeviceLabel: 'Speaker 1',
|
|
189
|
-
},
|
|
190
|
-
],
|
|
203
|
+
speaker: [{ selectedDeviceId, selectedDeviceLabel }],
|
|
191
204
|
}),
|
|
192
205
|
);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
it('does nothing when persistence is disabled', async () => {
|
|
209
|
+
const selectSpy = vi.spyOn(manager, 'select');
|
|
210
|
+
// @ts-expect-error - partial data
|
|
211
|
+
await manager.apply({});
|
|
212
|
+
expect(selectSpy).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('selects the persisted speaker device', async () => {
|
|
216
|
+
const persistedManager = createPersistedManager();
|
|
217
|
+
vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
|
|
218
|
+
of([
|
|
219
|
+
{
|
|
220
|
+
deviceId: 'speaker-1',
|
|
221
|
+
kind: 'audiooutput',
|
|
222
|
+
label: 'Speaker 1',
|
|
223
|
+
groupId: 'speaker-group',
|
|
224
|
+
} as MediaDeviceInfo,
|
|
225
|
+
]),
|
|
226
|
+
);
|
|
227
|
+
persist('speaker-1', 'Speaker 1');
|
|
193
228
|
|
|
194
229
|
const selectSpy = vi.spyOn(persistedManager, 'select');
|
|
195
230
|
// @ts-expect-error - partial data
|
|
196
|
-
persistedManager.apply({});
|
|
231
|
+
await persistedManager.apply({});
|
|
197
232
|
|
|
198
233
|
expect(selectSpy).toHaveBeenCalledWith('speaker-1');
|
|
199
234
|
expect(persistedManager.state.selectedDevice).toBe('speaker-1');
|
|
200
235
|
});
|
|
201
236
|
|
|
202
|
-
it('
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
id: '',
|
|
207
|
-
type: '',
|
|
208
|
-
streamClient,
|
|
209
|
-
clientEventReporter: new ClientEventReporter({ streamClient }),
|
|
210
|
-
clientStore: new StreamVideoWriteableStateStore(),
|
|
211
|
-
}),
|
|
212
|
-
{ enabled: true, storageKey },
|
|
237
|
+
it('does not select a missing persisted speaker device', async () => {
|
|
238
|
+
const persistedManager = createPersistedManager();
|
|
239
|
+
vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
|
|
240
|
+
of(mockAudioOutputDevices),
|
|
213
241
|
);
|
|
214
|
-
|
|
242
|
+
persist('speaker-1', 'Speaker 1');
|
|
215
243
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
244
|
+
const selectSpy = vi.spyOn(persistedManager, 'select');
|
|
245
|
+
// @ts-expect-error - partial data
|
|
246
|
+
await persistedManager.apply({});
|
|
247
|
+
|
|
248
|
+
expect(selectSpy).not.toHaveBeenCalled();
|
|
249
|
+
expect(persistedManager.state.selectedDevice).toBe('');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('does not restore a speaker preference when audio permission is not granted', async () => {
|
|
253
|
+
const persistedManager = createPersistedManager();
|
|
254
|
+
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
|
|
255
|
+
of('prompt'),
|
|
256
|
+
);
|
|
257
|
+
const listDevicesSpy = vi.spyOn(persistedManager, 'listDevices');
|
|
258
|
+
persist('speaker-1', 'Speaker 1');
|
|
259
|
+
|
|
260
|
+
const selectSpy = vi.spyOn(persistedManager, 'select');
|
|
261
|
+
// @ts-expect-error - partial data
|
|
262
|
+
await persistedManager.apply({});
|
|
263
|
+
|
|
264
|
+
expect(listDevicesSpy).not.toHaveBeenCalled();
|
|
265
|
+
expect(selectSpy).not.toHaveBeenCalled();
|
|
266
|
+
expect(persistedManager.state.selectedDevice).toBe('');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('does not restore a speaker preference from a non-output device with the same label', async () => {
|
|
270
|
+
const persistedManager = createPersistedManager();
|
|
271
|
+
const videoDevice = {
|
|
272
|
+
deviceId: 'video-device-id',
|
|
273
|
+
kind: 'videoinput',
|
|
274
|
+
label: 'RODECaster Video (19f7:006b)',
|
|
275
|
+
groupId: 'video-group',
|
|
276
|
+
} as MediaDeviceInfo;
|
|
277
|
+
const outputDevice = {
|
|
278
|
+
deviceId: 'speaker-device-id',
|
|
279
|
+
kind: 'audiooutput',
|
|
280
|
+
label: 'RODECaster Video (19f7:006b)',
|
|
281
|
+
groupId: 'speaker-group',
|
|
282
|
+
} as MediaDeviceInfo;
|
|
283
|
+
vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
|
|
284
|
+
of([outputDevice]),
|
|
226
285
|
);
|
|
286
|
+
persist('missing-speaker-device-id', outputDevice.label);
|
|
287
|
+
|
|
288
|
+
const selectSpy = vi.spyOn(persistedManager, 'select');
|
|
289
|
+
// @ts-expect-error - partial data
|
|
290
|
+
await persistedManager.apply({});
|
|
291
|
+
|
|
292
|
+
expect(selectSpy).toHaveBeenCalledWith(outputDevice.deviceId);
|
|
293
|
+
expect(selectSpy).not.toHaveBeenCalledWith(videoDevice.deviceId);
|
|
294
|
+
expect(persistedManager.state.selectedDevice).toBe(outputDevice.deviceId);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('does not restore a speaker preference from an empty label placeholder', async () => {
|
|
298
|
+
const persistedManager = createPersistedManager();
|
|
299
|
+
vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
|
|
300
|
+
of([
|
|
301
|
+
{
|
|
302
|
+
deviceId: '',
|
|
303
|
+
kind: 'audiooutput',
|
|
304
|
+
label: '',
|
|
305
|
+
groupId: '',
|
|
306
|
+
} as MediaDeviceInfo,
|
|
307
|
+
]),
|
|
308
|
+
);
|
|
309
|
+
persist('missing-speaker-device-id', '');
|
|
310
|
+
|
|
311
|
+
const selectSpy = vi.spyOn(persistedManager, 'select');
|
|
312
|
+
// @ts-expect-error - partial data
|
|
313
|
+
await persistedManager.apply({});
|
|
314
|
+
|
|
315
|
+
expect(selectSpy).not.toHaveBeenCalled();
|
|
316
|
+
expect(persistedManager.state.selectedDevice).toBe('');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('selects system default when persisted device is default', async () => {
|
|
320
|
+
const persistedManager = createPersistedManager();
|
|
321
|
+
persistedManager.select('previous-device');
|
|
322
|
+
persist(defaultDeviceId, '');
|
|
227
323
|
|
|
228
324
|
const selectSpy = vi.spyOn(persistedManager, 'select');
|
|
229
325
|
// @ts-expect-error - partial data
|
|
230
|
-
persistedManager.apply({});
|
|
326
|
+
await persistedManager.apply({});
|
|
231
327
|
|
|
232
328
|
expect(selectSpy).toHaveBeenCalledWith('');
|
|
233
329
|
expect(persistedManager.state.selectedDevice).toBe('');
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
OwnCapability,
|
|
7
7
|
} from '../../gen/coordinator';
|
|
8
8
|
import { Call } from '../../Call';
|
|
9
|
-
import { of,
|
|
9
|
+
import { of, ReplaySubject } from 'rxjs';
|
|
10
10
|
import { BrowserPermission } from '../BrowserPermission';
|
|
11
11
|
import { Tracer } from '../../stats';
|
|
12
12
|
|
|
@@ -71,6 +71,35 @@ export const mockAudioDevices = [
|
|
|
71
71
|
},
|
|
72
72
|
] as MediaDeviceInfo[];
|
|
73
73
|
|
|
74
|
+
export const mockAudioOutputDevices = [
|
|
75
|
+
{
|
|
76
|
+
deviceId:
|
|
77
|
+
'f1602a62d9d363c0711de07182751b646b87cfc0edd9e89d2849289e49304158',
|
|
78
|
+
kind: 'audiooutput',
|
|
79
|
+
label: 'RODECaster Video (19f7:006b)',
|
|
80
|
+
groupId: '27f43ef815ab224429532d6e8ebbeb00d7a895e0cbb0c2e4fcbe5b370f234259',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
deviceId:
|
|
84
|
+
'd542885e6a47d7a38837aee0323bd2c50e825971690f47295cb338e1f1061283',
|
|
85
|
+
kind: 'audiooutput',
|
|
86
|
+
label: 'MacBook Pro Speakers (Built-in)',
|
|
87
|
+
groupId: 'a8708545cb3969c0b7e3712d2ffdbc381992aeb5577b06cd71ba7a94ee20cf3e',
|
|
88
|
+
},
|
|
89
|
+
] as MediaDeviceInfo[];
|
|
90
|
+
|
|
91
|
+
export const mockDevicesWithoutAudioPermission = [
|
|
92
|
+
{ deviceId: '', kind: 'audioinput', label: '', groupId: '' },
|
|
93
|
+
{
|
|
94
|
+
deviceId:
|
|
95
|
+
'f10561a0bbd3effbd25645e7f06fc2e261255afeb05f6bc4fdaba3ed7bd9c2b7',
|
|
96
|
+
kind: 'videoinput',
|
|
97
|
+
label: 'RODECaster Video (19f7:006b)',
|
|
98
|
+
groupId: '27f43ef815ab224429532d6e8ebbeb00d7a895e0cbb0c2e4fcbe5b370f234259',
|
|
99
|
+
},
|
|
100
|
+
{ deviceId: '', kind: 'audiooutput', label: '', groupId: '' },
|
|
101
|
+
] as MediaDeviceInfo[];
|
|
102
|
+
|
|
74
103
|
export const mockCall = (): Partial<Call> => {
|
|
75
104
|
const callState = new CallState();
|
|
76
105
|
callState.setCallingState(CallingState.JOINED);
|
|
@@ -97,6 +126,7 @@ export const mockCall = (): Partial<Call> => {
|
|
|
97
126
|
return {
|
|
98
127
|
cid: 'default:test-call',
|
|
99
128
|
state: callState,
|
|
129
|
+
hasMediaEngine: true,
|
|
100
130
|
publish: vi.fn(),
|
|
101
131
|
stopPublish: vi.fn(),
|
|
102
132
|
streamClient: fromPartial({
|
|
@@ -106,6 +136,9 @@ export const mockCall = (): Partial<Call> => {
|
|
|
106
136
|
notifyNoiseCancellationStopped: vi.fn().mockResolvedValue(undefined),
|
|
107
137
|
notifyTrackMuteState: vi.fn().mockResolvedValue(undefined),
|
|
108
138
|
refreshPublishedTrack: vi.fn().mockResolvedValue(undefined),
|
|
139
|
+
ensureMediaFactory: vi.fn().mockResolvedValue({
|
|
140
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
141
|
+
}),
|
|
109
142
|
tracer: new Tracer('tests'),
|
|
110
143
|
};
|
|
111
144
|
};
|
|
@@ -252,9 +285,9 @@ export const createLocalStorageMock = (): LocalStorageMock => {
|
|
|
252
285
|
};
|
|
253
286
|
};
|
|
254
287
|
|
|
255
|
-
let deviceIds:
|
|
288
|
+
let deviceIds: ReplaySubject<MediaDeviceInfo[]>;
|
|
256
289
|
export const mockDeviceIds$ = () => {
|
|
257
|
-
deviceIds = new
|
|
290
|
+
deviceIds = new ReplaySubject(1);
|
|
258
291
|
return deviceIds;
|
|
259
292
|
};
|
|
260
293
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import '../../rtc/__tests__/mocks/webrtc.mocks';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import { CallState } from '../../store';
|
|
4
4
|
import { VisibilityState } from '../../types';
|
|
5
5
|
import { TrackType } from '../../gen/video/sfu/models/models';
|
|
@@ -192,6 +192,139 @@ describe('Participant events', () => {
|
|
|
192
192
|
expect(p?.screenShareStream).toBe(mediaStream);
|
|
193
193
|
expect(state.takeOrphanedTracks('track-lookup-prefix')).toHaveLength(0);
|
|
194
194
|
});
|
|
195
|
+
|
|
196
|
+
it('participantJoined should attach E2EE decryptor to orphaned track receiver', () => {
|
|
197
|
+
const state = new CallState();
|
|
198
|
+
const mediaStream = new MediaStream();
|
|
199
|
+
const receiver = {} as RTCRtpReceiver;
|
|
200
|
+
const e2ee = { decrypt: vi.fn() };
|
|
201
|
+
state.registerOrphanedTrack({
|
|
202
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
203
|
+
trackType: TrackType.VIDEO,
|
|
204
|
+
track: mediaStream,
|
|
205
|
+
id: mediaStream.id,
|
|
206
|
+
receiver,
|
|
207
|
+
});
|
|
208
|
+
// @ts-expect-error partial mock
|
|
209
|
+
const onParticipantJoined = watchParticipantJoined(state, () => e2ee);
|
|
210
|
+
onParticipantJoined({
|
|
211
|
+
// @ts-expect-error incomplete data
|
|
212
|
+
participant: {
|
|
213
|
+
userId: 'user-id',
|
|
214
|
+
sessionId: 'session-id',
|
|
215
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(e2ee.decrypt).toHaveBeenCalledWith(receiver, 'user-id', 'VIDEO');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('trackPublished should attach E2EE decryptor to orphaned track receiver', () => {
|
|
223
|
+
const state = new CallState();
|
|
224
|
+
const mediaStream = new MediaStream();
|
|
225
|
+
const receiver = {} as RTCRtpReceiver;
|
|
226
|
+
const e2ee = { decrypt: vi.fn() };
|
|
227
|
+
state.registerOrphanedTrack({
|
|
228
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
229
|
+
trackType: TrackType.AUDIO,
|
|
230
|
+
track: mediaStream,
|
|
231
|
+
id: mediaStream.id,
|
|
232
|
+
receiver,
|
|
233
|
+
});
|
|
234
|
+
// @ts-expect-error partial mock
|
|
235
|
+
const onTrackPublished = watchTrackPublished(state, () => e2ee);
|
|
236
|
+
onTrackPublished({
|
|
237
|
+
// @ts-expect-error incomplete data
|
|
238
|
+
participant: {
|
|
239
|
+
userId: 'user-id',
|
|
240
|
+
sessionId: 'session-id',
|
|
241
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(e2ee.decrypt).toHaveBeenCalledWith(receiver, 'user-id', 'AUDIO');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('trackUnpublished should attach E2EE decryptor to orphaned track receiver', () => {
|
|
249
|
+
const state = new CallState();
|
|
250
|
+
const mediaStream = new MediaStream();
|
|
251
|
+
const receiver = {} as RTCRtpReceiver;
|
|
252
|
+
const e2ee = { decrypt: vi.fn() };
|
|
253
|
+
state.registerOrphanedTrack({
|
|
254
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
255
|
+
trackType: TrackType.SCREEN_SHARE,
|
|
256
|
+
track: mediaStream,
|
|
257
|
+
id: mediaStream.id,
|
|
258
|
+
receiver,
|
|
259
|
+
});
|
|
260
|
+
// @ts-expect-error partial mock
|
|
261
|
+
const onTrackUnPublished = watchTrackUnpublished(state, () => e2ee);
|
|
262
|
+
onTrackUnPublished({
|
|
263
|
+
// @ts-expect-error incomplete data
|
|
264
|
+
participant: {
|
|
265
|
+
userId: 'user-id',
|
|
266
|
+
sessionId: 'session-id',
|
|
267
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(e2ee.decrypt).toHaveBeenCalledWith(
|
|
272
|
+
receiver,
|
|
273
|
+
'user-id',
|
|
274
|
+
'SCREEN_SHARE',
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should not call decrypt when no E2EE manager is provided', () => {
|
|
279
|
+
const state = new CallState();
|
|
280
|
+
const mediaStream = new MediaStream();
|
|
281
|
+
const receiver = {} as RTCRtpReceiver;
|
|
282
|
+
state.registerOrphanedTrack({
|
|
283
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
284
|
+
trackType: TrackType.VIDEO,
|
|
285
|
+
track: mediaStream,
|
|
286
|
+
id: mediaStream.id,
|
|
287
|
+
receiver,
|
|
288
|
+
});
|
|
289
|
+
const onParticipantJoined = watchParticipantJoined(state);
|
|
290
|
+
onParticipantJoined({
|
|
291
|
+
// @ts-expect-error incomplete data
|
|
292
|
+
participant: {
|
|
293
|
+
userId: 'user-id',
|
|
294
|
+
sessionId: 'session-id',
|
|
295
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const p = state.findParticipantBySessionId('session-id');
|
|
300
|
+
expect(p?.videoStream).toBe(mediaStream);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should not call decrypt when orphaned track has no receiver', () => {
|
|
304
|
+
const state = new CallState();
|
|
305
|
+
const mediaStream = new MediaStream();
|
|
306
|
+
const e2ee = { decrypt: vi.fn() };
|
|
307
|
+
state.registerOrphanedTrack({
|
|
308
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
309
|
+
trackType: TrackType.VIDEO,
|
|
310
|
+
track: mediaStream,
|
|
311
|
+
id: mediaStream.id,
|
|
312
|
+
});
|
|
313
|
+
// @ts-expect-error partial mock
|
|
314
|
+
const onParticipantJoined = watchParticipantJoined(state, () => e2ee);
|
|
315
|
+
onParticipantJoined({
|
|
316
|
+
// @ts-expect-error incomplete data
|
|
317
|
+
participant: {
|
|
318
|
+
userId: 'user-id',
|
|
319
|
+
sessionId: 'session-id',
|
|
320
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(e2ee.decrypt).not.toHaveBeenCalled();
|
|
325
|
+
const p = state.findParticipantBySessionId('session-id');
|
|
326
|
+
expect(p?.videoStream).toBe(mediaStream);
|
|
327
|
+
});
|
|
195
328
|
});
|
|
196
329
|
|
|
197
330
|
describe('trackPublished', () => {
|
|
@@ -40,6 +40,12 @@ type RingCallEvents = Extract<
|
|
|
40
40
|
*/
|
|
41
41
|
export const registerEventHandlers = (call: Call, dispatcher: Dispatcher) => {
|
|
42
42
|
const state = call.state;
|
|
43
|
+
// Read lazily on each event: setE2EEManager can run after setup() (an app that
|
|
44
|
+
// inspects call settings via get()/getOrCreate() before deciding to encrypt has
|
|
45
|
+
// already triggered it), so a value captured here would be a stale undefined
|
|
46
|
+
// for the whole call and orphaned tracks would never get a decryptor.
|
|
47
|
+
const e2ee = () => call.e2eeManager;
|
|
48
|
+
|
|
43
49
|
const eventHandlers = [
|
|
44
50
|
call.on('call.ended', watchCallEnded(call)),
|
|
45
51
|
watchSfuCallEnded(call),
|
|
@@ -49,12 +55,12 @@ export const registerEventHandlers = (call: Call, dispatcher: Dispatcher) => {
|
|
|
49
55
|
watchConnectionQualityChanged(dispatcher, state),
|
|
50
56
|
watchParticipantCountChanged(dispatcher, state),
|
|
51
57
|
|
|
52
|
-
call.on('participantJoined', watchParticipantJoined(state)),
|
|
58
|
+
call.on('participantJoined', watchParticipantJoined(state, e2ee)),
|
|
53
59
|
call.on('participantLeft', watchParticipantLeft(state)),
|
|
54
60
|
call.on('participantUpdated', watchParticipantUpdated(state)),
|
|
55
61
|
|
|
56
|
-
call.on('trackPublished', watchTrackPublished(state)),
|
|
57
|
-
call.on('trackUnpublished', watchTrackUnpublished(state)),
|
|
62
|
+
call.on('trackPublished', watchTrackPublished(state, e2ee)),
|
|
63
|
+
call.on('trackUnpublished', watchTrackUnpublished(state, e2ee)),
|
|
58
64
|
|
|
59
65
|
watchAudioLevelChanged(dispatcher, state),
|
|
60
66
|
watchDominantSpeakerChanged(dispatcher, state),
|