media-devices 0.4.0 → 0.5.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 +14 -1
- package/README.md +1 -1
- package/dist/media-devices.d.ts +129 -0
- package/dist/media-devices.js +136 -0
- package/dist/media-devices.umd.cjs +1 -0
- package/package.json +23 -22
- package/dist/media-devices.es.d.ts +0 -2
- package/dist/media-devices.es.js +0 -132
- package/dist/media-devices.es.js.map +0 -1
- package/dist/media-devices.umd.d.ts +0 -2
- package/dist/media-devices.umd.js +0 -2
- package/dist/media-devices.umd.js.map +0 -1
- package/src/__tests__/device-manager.test.ts +0 -234
- package/src/__tests__/enumerate-devices.test.ts +0 -58
- package/src/__tests__/get-user-media.test.ts +0 -16
- package/src/__tests__/support-detection.test.ts +0 -33
- package/src/device-manager.ts +0 -240
- package/src/enumerate-devices.ts +0 -72
- package/src/get-user-media.ts +0 -7
- package/src/index.ts +0 -10
- package/src/support-detection.ts +0 -16
- package/src/test-utils/index.ts +0 -22
- package/src/test-utils/mocks.ts +0 -28
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
import DeviceManager, { OperationType } from '../device-manager';
|
|
2
|
-
import { setDeviceList } from '../test-utils';
|
|
3
|
-
import { getMediaDevicesApi } from '../support-detection';
|
|
4
|
-
|
|
5
|
-
describe('DeviceManager', () => {
|
|
6
|
-
beforeEach(() => {
|
|
7
|
-
(getMediaDevicesApi() as any).removeAllListeners('devicechange');
|
|
8
|
-
(getMediaDevicesApi() as any).enumerateDevices.mockClear();
|
|
9
|
-
setDeviceList([]);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
const setup = () => {
|
|
13
|
-
const handler = jest.fn();
|
|
14
|
-
const devices = new DeviceManager();
|
|
15
|
-
devices.ondevicechange = handler;
|
|
16
|
-
|
|
17
|
-
return {
|
|
18
|
-
handler,
|
|
19
|
-
devices,
|
|
20
|
-
};
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
it('returns the full list of devices when queried', async () => {
|
|
24
|
-
const { devices } = setup();
|
|
25
|
-
const expectedDevices = setDeviceList([{ label: 'telescreen' }]);
|
|
26
|
-
|
|
27
|
-
await expect(devices.enumerateDevices()).resolves.toEqual(expectedDevices);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('detects device changes between queries', async () => {
|
|
31
|
-
const { handler, devices } = setup();
|
|
32
|
-
setDeviceList([{}]);
|
|
33
|
-
|
|
34
|
-
const [device] = await devices.enumerateDevices();
|
|
35
|
-
|
|
36
|
-
expect(handler).toHaveBeenCalledWith({
|
|
37
|
-
changes: [{ type: OperationType.Add, device }],
|
|
38
|
-
devices: [device],
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('does not duplicate change notifications to subscribers', async () => {
|
|
43
|
-
const { handler, devices } = setup();
|
|
44
|
-
setDeviceList([{}]);
|
|
45
|
-
|
|
46
|
-
await devices.enumerateDevices();
|
|
47
|
-
await devices.enumerateDevices();
|
|
48
|
-
|
|
49
|
-
expect(handler).toHaveBeenCalledTimes(1);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('detects removed devices', async () => {
|
|
53
|
-
setDeviceList([{}]);
|
|
54
|
-
const { devices, handler } = setup();
|
|
55
|
-
|
|
56
|
-
const [device] = await devices.enumerateDevices();
|
|
57
|
-
setDeviceList([]);
|
|
58
|
-
|
|
59
|
-
handler.mockClear();
|
|
60
|
-
await devices.enumerateDevices();
|
|
61
|
-
|
|
62
|
-
expect(handler).toHaveBeenCalledWith({
|
|
63
|
-
changes: [{ type: OperationType.Remove, device }],
|
|
64
|
-
devices: expect.any(Array),
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('correlates identical devices between calls', async () => {
|
|
69
|
-
const [device] = setDeviceList([{ label: 'first' }]);
|
|
70
|
-
const { handler, devices } = setup();
|
|
71
|
-
await devices.enumerateDevices();
|
|
72
|
-
setDeviceList([device, { label: 'second' }]);
|
|
73
|
-
|
|
74
|
-
handler.mockClear();
|
|
75
|
-
const [, secondDevice] = await devices.enumerateDevices();
|
|
76
|
-
|
|
77
|
-
expect(handler).toHaveBeenCalledWith({
|
|
78
|
-
changes: [{ type: OperationType.Add, device: secondDevice }],
|
|
79
|
-
devices: expect.any(Array),
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('infers device relationships when the ID was just added', async () => {
|
|
84
|
-
const { handler, devices } = setup();
|
|
85
|
-
const kind = 'videoinput' as const;
|
|
86
|
-
const groupId = 'group-id';
|
|
87
|
-
const redactedDevice = { label: '', deviceId: '', groupId, kind };
|
|
88
|
-
const device = {
|
|
89
|
-
...redactedDevice,
|
|
90
|
-
label: 'Creepy Shelf Elf',
|
|
91
|
-
deviceId: "sh'elf",
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
// Simulates fingerprinting countermeasures.
|
|
95
|
-
setDeviceList([redactedDevice]);
|
|
96
|
-
await devices.enumerateDevices();
|
|
97
|
-
|
|
98
|
-
// Same device after the first approved `getUserMedia(...)` request.
|
|
99
|
-
setDeviceList([device]);
|
|
100
|
-
|
|
101
|
-
handler.mockClear();
|
|
102
|
-
await devices.enumerateDevices();
|
|
103
|
-
|
|
104
|
-
// It should detect that it's the same device.
|
|
105
|
-
expect(handler).toHaveBeenCalledWith({
|
|
106
|
-
devices: expect.any(Array),
|
|
107
|
-
changes: [
|
|
108
|
-
{
|
|
109
|
-
type: OperationType.Update,
|
|
110
|
-
oldInfo: { ...device, deviceId: null, label: null },
|
|
111
|
-
newInfo: device,
|
|
112
|
-
},
|
|
113
|
-
],
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// *scowls at Safari*
|
|
118
|
-
it('infers similarity between devices with omitted group IDs', async () => {
|
|
119
|
-
const redactedDevice = {
|
|
120
|
-
label: '',
|
|
121
|
-
deviceId: '',
|
|
122
|
-
groupId: '',
|
|
123
|
-
kind: 'audioinput' as const,
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
const device = {
|
|
127
|
-
...redactedDevice,
|
|
128
|
-
label: '3D Scanner',
|
|
129
|
-
deviceId: 'wec3d',
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
setDeviceList([redactedDevice]);
|
|
133
|
-
const { handler, devices } = setup();
|
|
134
|
-
await devices.enumerateDevices();
|
|
135
|
-
|
|
136
|
-
setDeviceList([device]);
|
|
137
|
-
handler.mockClear();
|
|
138
|
-
await devices.enumerateDevices();
|
|
139
|
-
|
|
140
|
-
// It should detect that it's the same device.
|
|
141
|
-
expect(handler).toHaveBeenCalledWith({
|
|
142
|
-
devices: expect.any(Array),
|
|
143
|
-
changes: [
|
|
144
|
-
{
|
|
145
|
-
type: OperationType.Update,
|
|
146
|
-
oldInfo: { ...device, deviceId: null, groupId: null, label: null },
|
|
147
|
-
newInfo: { ...device, groupId: null },
|
|
148
|
-
},
|
|
149
|
-
],
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('watches the device list for changes at the OS level', async () => {
|
|
154
|
-
const { handler, devices } = setup();
|
|
155
|
-
await devices.enumerateDevices();
|
|
156
|
-
|
|
157
|
-
handler.mockClear();
|
|
158
|
-
setDeviceList([{ label: 'Telescope' }]);
|
|
159
|
-
const [listener] = (getMediaDevicesApi() as any).listeners('devicechange');
|
|
160
|
-
|
|
161
|
-
await listener();
|
|
162
|
-
|
|
163
|
-
expect(handler).toHaveBeenCalledWith({
|
|
164
|
-
changes: [expect.objectContaining({ type: OperationType.Add })],
|
|
165
|
-
devices: expect.any(Array),
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('only watches the device list if there are subscribers', async () => {
|
|
170
|
-
new DeviceManager();
|
|
171
|
-
|
|
172
|
-
setDeviceList([{}]);
|
|
173
|
-
const [listener] = (getMediaDevicesApi() as any).listeners('devicechange');
|
|
174
|
-
|
|
175
|
-
await listener();
|
|
176
|
-
|
|
177
|
-
expect(getMediaDevicesApi().enumerateDevices).not.toHaveBeenCalled();
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('refreshes the device list after a successful GUM query', async () => {
|
|
181
|
-
setDeviceList([{ label: '' }]);
|
|
182
|
-
const { devices } = setup();
|
|
183
|
-
await devices.getUserMedia({ video: true });
|
|
184
|
-
|
|
185
|
-
expect(getMediaDevicesApi().enumerateDevices).toHaveBeenCalled();
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('returns the supported constraints when requested', () => {
|
|
189
|
-
(getMediaDevicesApi() as any).getSupportedConstraints.mockReturnValue({
|
|
190
|
-
mock: 'supported-constraints',
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
const { devices } = setup();
|
|
194
|
-
const constraints = devices.getSupportedConstraints();
|
|
195
|
-
|
|
196
|
-
expect(constraints).toEqual(
|
|
197
|
-
navigator.mediaDevices.getSupportedConstraints()
|
|
198
|
-
);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('returns the display media stream when requested', async () => {
|
|
202
|
-
const stream = new MediaStream();
|
|
203
|
-
(getMediaDevicesApi() as any).getDisplayMedia.mockResolvedValue(stream);
|
|
204
|
-
|
|
205
|
-
const { devices } = setup();
|
|
206
|
-
|
|
207
|
-
await expect(devices.getDisplayMedia()).resolves.toBe(stream);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('refreshes the device list after a successful display query', async () => {
|
|
211
|
-
setDeviceList([{ label: '' }]);
|
|
212
|
-
const { devices } = setup();
|
|
213
|
-
await devices.getDisplayMedia({ video: true });
|
|
214
|
-
|
|
215
|
-
expect(getMediaDevicesApi().enumerateDevices).toHaveBeenCalled();
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('only refreshes the device list after the first successful GDM', async () => {
|
|
219
|
-
setDeviceList([{ label: '' }]);
|
|
220
|
-
const { devices } = setup();
|
|
221
|
-
|
|
222
|
-
await devices.getDisplayMedia({ video: true });
|
|
223
|
-
await devices.getDisplayMedia({ video: true });
|
|
224
|
-
await devices.getDisplayMedia({ video: true });
|
|
225
|
-
|
|
226
|
-
expect(getMediaDevicesApi().enumerateDevices).toHaveBeenCalledTimes(1);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it('survives even if the media devices API is unsupported', () => {
|
|
230
|
-
delete (navigator as any).mediaDevices;
|
|
231
|
-
|
|
232
|
-
expect(setup).not.toThrow();
|
|
233
|
-
});
|
|
234
|
-
});
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import enumerateDevices from '../enumerate-devices';
|
|
2
|
-
import { setDeviceList } from '../test-utils';
|
|
3
|
-
|
|
4
|
-
describe('Device enumeration', () => {
|
|
5
|
-
beforeEach(() => {
|
|
6
|
-
setDeviceList([]);
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
it('returns a list of devices', async () => {
|
|
10
|
-
const device = { label: 'Selfie Stick' };
|
|
11
|
-
setDeviceList([device]);
|
|
12
|
-
|
|
13
|
-
const devices = await enumerateDevices();
|
|
14
|
-
|
|
15
|
-
expect(devices).toHaveLength(1);
|
|
16
|
-
expect(devices[0]).toMatchObject(device);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('explicitly represents obfuscated fields', async () => {
|
|
20
|
-
setDeviceList([{ label: '', deviceId: '', groupId: '' }]);
|
|
21
|
-
const [device] = await enumerateDevices();
|
|
22
|
-
|
|
23
|
-
expect(device).toMatchObject({
|
|
24
|
-
label: null,
|
|
25
|
-
deviceId: null,
|
|
26
|
-
groupId: null,
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('adds device metadata', async () => {
|
|
31
|
-
const device = {
|
|
32
|
-
kind: 'audioinput' as const,
|
|
33
|
-
label: 'Nest Thermostat',
|
|
34
|
-
deviceId: 'device-id',
|
|
35
|
-
groupId: 'group-id',
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
setDeviceList([device]);
|
|
39
|
-
|
|
40
|
-
const devices = await enumerateDevices();
|
|
41
|
-
|
|
42
|
-
expect(devices[0]).toMatchObject(device);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('strips default meta-devices', async () => {
|
|
46
|
-
const device = {
|
|
47
|
-
label: 'Surveillance Camera #451',
|
|
48
|
-
deviceId: 'device-id',
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
setDeviceList([device, { ...device, deviceId: 'default' }]);
|
|
52
|
-
|
|
53
|
-
const devices = await enumerateDevices();
|
|
54
|
-
|
|
55
|
-
expect(devices).toHaveLength(1);
|
|
56
|
-
expect(devices[0]).toMatchObject({ deviceId: device.deviceId });
|
|
57
|
-
});
|
|
58
|
-
});
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import getUserMedia from '../get-user-media';
|
|
2
|
-
import { getMediaDevicesApi } from '../support-detection';
|
|
3
|
-
|
|
4
|
-
describe('getUserMedia()', () => {
|
|
5
|
-
beforeEach(() => {
|
|
6
|
-
(getMediaDevicesApi() as any).getUserMedia.mockResolvedValue(
|
|
7
|
-
new MediaStream()
|
|
8
|
-
);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('returns the media stream', async () => {
|
|
12
|
-
const stream = await getUserMedia({ video: true });
|
|
13
|
-
|
|
14
|
-
expect(stream).toBeInstanceOf(MediaStream);
|
|
15
|
-
});
|
|
16
|
-
});
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { supportsMediaDevices, getMediaDevicesApi } from '../support-detection';
|
|
2
|
-
|
|
3
|
-
describe('Support detection', () => {
|
|
4
|
-
const mockMediaDevices = <T>(MediaDevices: T) => {
|
|
5
|
-
(navigator as any).mediaDevices = MediaDevices;
|
|
6
|
-
};
|
|
7
|
-
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
mockMediaDevices({ mock: 'MediaDevices' });
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
describe('supportsMediaDevices()', () => {
|
|
13
|
-
it('returns false with no support', () => {
|
|
14
|
-
mockMediaDevices(null);
|
|
15
|
-
expect(supportsMediaDevices()).toBe(false);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('returns true when the object exists', () => {
|
|
19
|
-
expect(supportsMediaDevices()).toBe(true);
|
|
20
|
-
});
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe('getMediaDevicesApi', () => {
|
|
24
|
-
it('throws if the API is unsupported', () => {
|
|
25
|
-
(navigator as any).mediaDevices = undefined;
|
|
26
|
-
expect(getMediaDevicesApi).toThrow(/media ?devices/i);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('returns the media devices API', () => {
|
|
30
|
-
expect(getMediaDevicesApi()).toBe(navigator.mediaDevices);
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
|
-
});
|
package/src/device-manager.ts
DELETED
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
import enumerateDevices, { DeviceInfo } from './enumerate-devices';
|
|
2
|
-
import { getMediaDevicesApi, supportsMediaDevices } from './support-detection';
|
|
3
|
-
import getUserMedia from './get-user-media';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Monitors the set of devices for changes and calculates convenient diffs
|
|
7
|
-
* between updates. Steps are taken to handle cross-browser quirks and
|
|
8
|
-
* attempts graceful integration with browser fingerprinting countermeasures.
|
|
9
|
-
*/
|
|
10
|
-
export default class DeviceManager {
|
|
11
|
-
private _knownDevices: Array<DeviceInfo> = [];
|
|
12
|
-
private _gainedScreenAccessOnce = false;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Specifies a function to be called whenever the list of available devices
|
|
16
|
-
* changes.
|
|
17
|
-
*
|
|
18
|
-
* Note: this is different from the native event. It passes the changeset
|
|
19
|
-
* and full list of devices as a parameter.
|
|
20
|
-
*/
|
|
21
|
-
ondevicechange: null | DeviceChangeListener = null;
|
|
22
|
-
|
|
23
|
-
constructor() {
|
|
24
|
-
// Listen for changes at the OS level. If the device list changes and
|
|
25
|
-
// someone's around to see it, refresh the device list. Refreshing has
|
|
26
|
-
// a side effect of performing a diff and telling all subscribers about
|
|
27
|
-
// the change.
|
|
28
|
-
if (supportsMediaDevices()) {
|
|
29
|
-
getMediaDevicesApi().addEventListener('devicechange', () => {
|
|
30
|
-
if (this.ondevicechange) {
|
|
31
|
-
return this.enumerateDevices();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return Promise.resolve();
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Request a live media stream from audio and/or video devices. Streams are
|
|
41
|
-
* configurable through constraints.
|
|
42
|
-
* See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
|
|
43
|
-
*/
|
|
44
|
-
getUserMedia = async (constraints: MediaStreamConstraints) => {
|
|
45
|
-
const stream = await getUserMedia(constraints);
|
|
46
|
-
|
|
47
|
-
// The browser considers us trusted after the first approved GUM query and
|
|
48
|
-
// allows access to more information in the device list, which is an
|
|
49
|
-
// implicit device change event. Refresh to update the cache.
|
|
50
|
-
//
|
|
51
|
-
// We do this for every GUM request because some browsers only allow
|
|
52
|
-
// access to the subset of devices you've been approved for. While
|
|
53
|
-
// reasonable from a security perspective, it means we're never sure if
|
|
54
|
-
// the cache is stale.
|
|
55
|
-
this.enumerateDevices();
|
|
56
|
-
|
|
57
|
-
return stream;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Ask the user to share their screen. Resolves with a media stream carrying
|
|
62
|
-
* video, and potentially audio from the application window.
|
|
63
|
-
* See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia
|
|
64
|
-
*/
|
|
65
|
-
getDisplayMedia = async (
|
|
66
|
-
constraints?: MediaStreamConstraints
|
|
67
|
-
): Promise<MediaStream> => {
|
|
68
|
-
const stream = await getMediaDevicesApi().getDisplayMedia(constraints);
|
|
69
|
-
|
|
70
|
-
// Similar to `getUserMedia(...)`, granting access to your screen implies
|
|
71
|
-
// a certain level of trust. Some browsers will remove the fingerprinting
|
|
72
|
-
// protections after the first successful call. However, it's unlikely
|
|
73
|
-
// that another will tell us anything more, so we only refresh devices
|
|
74
|
-
// after the first success.
|
|
75
|
-
if (!this._gainedScreenAccessOnce) {
|
|
76
|
-
this._gainedScreenAccessOnce = true;
|
|
77
|
-
this.enumerateDevices();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return stream;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Lists every available hardware device, including microphones, cameras,
|
|
85
|
-
* and speakers (depending on browser support). May contain redacted
|
|
86
|
-
* information depending on application permissions.
|
|
87
|
-
*/
|
|
88
|
-
enumerateDevices = async (): Promise<Array<DeviceInfo>> => {
|
|
89
|
-
const devices = await enumerateDevices();
|
|
90
|
-
this._checkForDeviceChanges(devices);
|
|
91
|
-
|
|
92
|
-
return devices;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Returns an object containing every media constraint supported by the
|
|
97
|
-
* browser.
|
|
98
|
-
* See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getSupportedConstraints
|
|
99
|
-
*/
|
|
100
|
-
getSupportedConstraints = (): MediaTrackSupportedConstraints => {
|
|
101
|
-
return getMediaDevicesApi().getSupportedConstraints();
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
private _checkForDeviceChanges(newDevices: Array<DeviceInfo>) {
|
|
105
|
-
const oldDevices = this._knownDevices;
|
|
106
|
-
this._knownDevices = newDevices; // Replace the old devices.
|
|
107
|
-
|
|
108
|
-
const changes: Array<DeviceChange> = this._calculateDeviceDiff(
|
|
109
|
-
newDevices,
|
|
110
|
-
oldDevices
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
if (changes.length) {
|
|
114
|
-
this.ondevicechange?.({ changes, devices: newDevices });
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Note: The device enumeration API may return null values for device IDs
|
|
120
|
-
* and labels. To avoid creating erroneous "Device Added" notifications,
|
|
121
|
-
* a best effort should be made to detect when devices are identical.
|
|
122
|
-
*
|
|
123
|
-
* Order is significant. Preferred devices are listed first, which helps
|
|
124
|
-
* correlate devices from permissioned requests with unpermissioned
|
|
125
|
-
* requests.
|
|
126
|
-
*/
|
|
127
|
-
private _calculateDeviceDiff(
|
|
128
|
-
newDevices: Array<DeviceInfo>,
|
|
129
|
-
oldDevices: Array<DeviceInfo>
|
|
130
|
-
): Array<DeviceChange> {
|
|
131
|
-
const removals = oldDevices.slice();
|
|
132
|
-
const updates: Array<DeviceChange> = [];
|
|
133
|
-
|
|
134
|
-
// If a "new" device exists in the list of old devices, then it obviously
|
|
135
|
-
// wasn't just added and clearly we haven't removed it either. It's the
|
|
136
|
-
// same device.
|
|
137
|
-
const additions = newDevices.filter((newDevice) => {
|
|
138
|
-
const oldDeviceIndex = removals.findIndex((oldDevice) => {
|
|
139
|
-
return isIdenticalDevice(newDevice, oldDevice);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// Note: Nasty state mutation hides here.
|
|
143
|
-
// Maps/Sets are out of the question due to poor TS support. Plus IDs
|
|
144
|
-
// are far too unreliable in this context. Iteration and splice() are
|
|
145
|
-
// ugly and gross, but they work.
|
|
146
|
-
if (oldDeviceIndex > -1) {
|
|
147
|
-
const [oldDevice] = removals.splice(oldDeviceIndex, 1);
|
|
148
|
-
|
|
149
|
-
if (newDevice.label !== oldDevice.label) {
|
|
150
|
-
const update: DeviceUpdateEvent = {
|
|
151
|
-
type: OperationType.Update,
|
|
152
|
-
newInfo: newDevice,
|
|
153
|
-
oldInfo: oldDevice,
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
updates.push(update);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Only count it as an "addition" if we couldn't find the same device in
|
|
161
|
-
// the older set.
|
|
162
|
-
return oldDeviceIndex === -1;
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
return [
|
|
166
|
-
...updates,
|
|
167
|
-
|
|
168
|
-
// A device was just removed.
|
|
169
|
-
...removals.map((device) => {
|
|
170
|
-
return { type: OperationType.Remove, device } as DeviceRemoveEvent;
|
|
171
|
-
}),
|
|
172
|
-
|
|
173
|
-
// A device was just plugged in.
|
|
174
|
-
...additions.map((device) => {
|
|
175
|
-
return { type: OperationType.Add, device } as DeviceAddEvent;
|
|
176
|
-
}),
|
|
177
|
-
];
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Due to fingerprinting countermeasures, the device ID might be an empty
|
|
183
|
-
* string. We have to resort to vague comparisons. After the first successful
|
|
184
|
-
* `getUserMedia(...)` query, the device ID for all related device kinds
|
|
185
|
-
* should be revealed. In that case the new device will have an ID but the old
|
|
186
|
-
* device won't. It should be safe to assume the inverse never happens.
|
|
187
|
-
*
|
|
188
|
-
* Note: Chromium browsers take a private stance by hiding your extra devices.
|
|
189
|
-
* Even if you have a hundred cameras plugged in, until that first GUM query,
|
|
190
|
-
* you'll only see the preferred one. Same for microphones and output devices.
|
|
191
|
-
*/
|
|
192
|
-
function isIdenticalDevice(newDevice: DeviceInfo, oldDevice: DeviceInfo) {
|
|
193
|
-
if (oldDevice.deviceId) {
|
|
194
|
-
return newDevice.deviceId === oldDevice.deviceId;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// These are the only credible fields we have to go on. It may yield a false
|
|
198
|
-
// positive if you're changing devices before the first GUM query, but since
|
|
199
|
-
// the lists are ordered by priority, that should be unlikely. It's
|
|
200
|
-
// certainly preferable to "new device" false positives.
|
|
201
|
-
function toCrudeId(device: DeviceInfo) {
|
|
202
|
-
return `${device.kind}:${device.groupId}`;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return toCrudeId(newDevice) === toCrudeId(oldDevice);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export type DeviceChange =
|
|
209
|
-
| DeviceAddEvent
|
|
210
|
-
| DeviceRemoveEvent
|
|
211
|
-
| DeviceUpdateEvent;
|
|
212
|
-
|
|
213
|
-
interface DeviceAddEvent {
|
|
214
|
-
type: OperationType.Add;
|
|
215
|
-
device: DeviceInfo;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
interface DeviceRemoveEvent {
|
|
219
|
-
type: OperationType.Remove;
|
|
220
|
-
device: DeviceInfo;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
interface DeviceUpdateEvent {
|
|
224
|
-
type: OperationType.Update;
|
|
225
|
-
newInfo: DeviceInfo;
|
|
226
|
-
oldInfo: DeviceInfo;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export enum OperationType {
|
|
230
|
-
Add = 'add',
|
|
231
|
-
Remove = 'remove',
|
|
232
|
-
Update = 'update',
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
interface DeviceChangeListener {
|
|
236
|
-
(update: {
|
|
237
|
-
changes: Array<DeviceChange>;
|
|
238
|
-
devices: Array<DeviceInfo>;
|
|
239
|
-
}): unknown;
|
|
240
|
-
}
|
package/src/enumerate-devices.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { getMediaDevicesApi } from './support-detection';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A normalization layer over `MediaDevices.enumerateDevices()`:
|
|
5
|
-
* https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo
|
|
6
|
-
*
|
|
7
|
-
* The API is fraught with cross-browser quirks and fingerprinting blocks.
|
|
8
|
-
* This interface seeks to normalize some of those quirks and make the
|
|
9
|
-
* security tradeoffs obvious.
|
|
10
|
-
*/
|
|
11
|
-
export default async function enumerateDevices(): Promise<Array<DeviceInfo>> {
|
|
12
|
-
const devices = await getMediaDevicesApi().enumerateDevices();
|
|
13
|
-
return devices.filter(isPhysicalDevice).map(normalizeDeviceInfo);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Chromium does this really annoying thing where it duplicates preferred
|
|
18
|
-
* devices by substituting the ID with "default". No other browser does this,
|
|
19
|
-
* and preferred devices are already represented by list order.
|
|
20
|
-
*
|
|
21
|
-
* Since those meta-devices don't add relevant information and risk confusing
|
|
22
|
-
* device UIs, I simply remove them.
|
|
23
|
-
*/
|
|
24
|
-
function isPhysicalDevice(device: MediaDeviceInfo) {
|
|
25
|
-
return device.deviceId !== 'default';
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Make nullable fields explicit.
|
|
29
|
-
function normalizeDeviceInfo(device: MediaDeviceInfo): DeviceInfo {
|
|
30
|
-
return {
|
|
31
|
-
label: device.label || null,
|
|
32
|
-
kind: device.kind as DeviceKind,
|
|
33
|
-
deviceId: device.deviceId || null,
|
|
34
|
-
groupId: device.groupId || null,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface DeviceInfo {
|
|
39
|
-
/**
|
|
40
|
-
* The device list is obfuscated until you gain elevated permissions.
|
|
41
|
-
* Browsers will use an empty string for the device label until the first
|
|
42
|
-
* successful `getUserMedia(...)` request.
|
|
43
|
-
*/
|
|
44
|
-
label: null | string;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* A unique identifier persistent across sessions. Note: In Chromium
|
|
48
|
-
* browsers, this can be unset if you haven't received permission for the
|
|
49
|
-
* media resource yet.
|
|
50
|
-
*/
|
|
51
|
-
deviceId: null | string;
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* A unique identifier grouping one or more devices together. Two devices
|
|
55
|
-
* with the same group ID symbolise that both devices belong to the same
|
|
56
|
-
* hardware, e.g. a webcam with an integrated microphone. Note: Safari
|
|
57
|
-
* doesn't support group IDs.
|
|
58
|
-
*/
|
|
59
|
-
groupId: null | string;
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Declares the type of media provided. This covers microphones, cameras,
|
|
63
|
-
* and speakers.
|
|
64
|
-
*/
|
|
65
|
-
kind: DeviceKind;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export enum DeviceKind {
|
|
69
|
-
VideoInput = 'videoinput',
|
|
70
|
-
AudioInput = 'audioinput',
|
|
71
|
-
AudioOutput = 'audiooutput',
|
|
72
|
-
}
|
package/src/get-user-media.ts
DELETED
package/src/index.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import DeviceManager from './device-manager';
|
|
2
|
-
|
|
3
|
-
export { supportsMediaDevices } from './support-detection';
|
|
4
|
-
export { DeviceKind } from './enumerate-devices';
|
|
5
|
-
export { OperationType } from './device-manager';
|
|
6
|
-
|
|
7
|
-
export type { DeviceInfo } from './enumerate-devices';
|
|
8
|
-
export type { DeviceChange } from './device-manager';
|
|
9
|
-
|
|
10
|
-
export default new DeviceManager();
|
package/src/support-detection.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Not all browsers support media devices, and some restrict access for
|
|
3
|
-
* insecure sites and private contexts. This is often reflected by removing
|
|
4
|
-
* the `mediaDevices` API entirely.
|
|
5
|
-
*/
|
|
6
|
-
export function supportsMediaDevices() {
|
|
7
|
-
return typeof navigator !== 'undefined' && !!navigator.mediaDevices;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function getMediaDevicesApi() {
|
|
11
|
-
if (!supportsMediaDevices()) {
|
|
12
|
-
throw new Error(`The media devices API isn't supported here.`);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
return navigator.mediaDevices;
|
|
16
|
-
}
|