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.
@@ -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
- });
@@ -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
- }
@@ -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
- }
@@ -1,7 +0,0 @@
1
- import { getMediaDevicesApi } from './support-detection';
2
-
3
- export default async function getUserMedia(
4
- constraints: MediaStreamConstraints
5
- ): Promise<MediaStream> {
6
- return getMediaDevicesApi().getUserMedia(constraints);
7
- }
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();
@@ -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
- }