@desklink/react-native 0.4.0 → 0.5.1

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.
@@ -0,0 +1,205 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import React from 'react';
3
+ import TestRenderer, { act } from 'react-test-renderer';
4
+
5
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
6
+
7
+ /** The key row and the clipboard pill, rendered with `react-native` primitives as host strings. */
8
+ vi.mock('react-native', () => ({
9
+ StyleSheet: { create: (styles: unknown) => styles, absoluteFill: {} },
10
+ Linking: { getInitialURL: async () => null, addEventListener: () => ({ remove: () => undefined }) },
11
+ Platform: { OS: 'ios' },
12
+ Pressable: 'Pressable',
13
+ ScrollView: 'ScrollView',
14
+ StatusBar: 'StatusBar',
15
+ Text: 'Text',
16
+ View: 'View',
17
+ Settings: { get: () => 'ws://localhost/desktop?token=test' },
18
+ }));
19
+
20
+ vi.mock('react-native-safe-area-context', () => ({
21
+ SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children,
22
+ useSafeAreaInsets: () => ({ top: 0, left: 0, bottom: 0, right: 0 }),
23
+ }));
24
+
25
+ const example = vi.hoisted(() => ({
26
+ write: vi.fn(),
27
+ copy: vi.fn(),
28
+ connect: vi.fn(),
29
+ }));
30
+ vi.mock('expo-clipboard', () => ({ setStringAsync: example.write }));
31
+ vi.mock('@desklink/react-native', async () => ({
32
+ CONTROL_PERMISSIONS: ['view', 'control', 'clipboard'],
33
+ ClipboardConfirmation: (await import('./ClipboardConfirmation')).ClipboardConfirmation,
34
+ ModifierKeys: (await import('./ModifierKeys')).ModifierKeys,
35
+ DesktopView: 'DesktopView',
36
+ useDesktopSession: () => ({
37
+ snapshot: { status: 'live' },
38
+ nativeId: 'example-session',
39
+ modifiers: { Control: 'off', Shift: 'off', Alt: 'off', Meta: 'off' },
40
+ clipboard: null,
41
+ connect: example.connect,
42
+ setInputEnabled: vi.fn(),
43
+ copyRemoteToLocal: example.copy,
44
+ tapModifier: vi.fn(),
45
+ }),
46
+ }));
47
+
48
+ import { KEY_COLORS, ModifierKeys } from './ModifierKeys';
49
+ import { CLIPBOARD_CONFIRMATION_MS, ClipboardConfirmation, PILL_COLORS, describeTransfer } from './ClipboardConfirmation';
50
+ import { NO_MODIFIERS } from './stickyModifiers';
51
+ import type { ClipboardTransfer } from './useDesktopSession';
52
+ import App from '../example/App';
53
+
54
+ type Style = Record<string, unknown>;
55
+ const flatten = (style: unknown): Style =>
56
+ Array.isArray(style) ? Object.assign({}, ...style.map(flatten)) : style ? (style as Style) : {};
57
+
58
+ /** WCAG contrast of two colours, the first drawn with its alpha over `under`. */
59
+ function contrast(top: string, bottom: string, under = '#000000'): number {
60
+ const rgba = (c: string) => {
61
+ const m = c.match(/rgba?\(([^)]+)\)/);
62
+ if (m) { const [r, g, b, a = 1] = m[1].split(',').map(Number); return [r, g, b, a]; }
63
+ return [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)).concat(1);
64
+ };
65
+ const over = (c: string) => {
66
+ const [r, g, b, a] = rgba(c);
67
+ const [ur, ug, ub] = rgba(under);
68
+ return [r * a + ur * (1 - a), g * a + ug * (1 - a), b * a + ub * (1 - a)];
69
+ };
70
+ const lum = (rgb: number[]) => {
71
+ const [r, g, b] = rgb.map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; });
72
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
73
+ };
74
+ const [l1, l2] = [lum(over(top)), lum(over(bottom))].sort((a, b) => b - a);
75
+ return (l1 + 0.05) / (l2 + 0.05);
76
+ }
77
+
78
+ describe('ModifierKeys', () => {
79
+ function render(modifiers = NO_MODIFIERS, onTap = vi.fn()) {
80
+ let tree!: TestRenderer.ReactTestRenderer;
81
+ act(() => { tree = TestRenderer.create(<ModifierKeys modifiers={modifiers} onTap={onTap} />); });
82
+ const key = (name: string) => tree.root.find((node) => node.props.testID === `desklink-key-${name}`);
83
+ return { key, onTap };
84
+ }
85
+
86
+ it('draws off, latched and locked as three different keys and names each state', () => {
87
+ const { key } = render({ Control: 'off', Shift: 'once', Alt: 'lock', Meta: 'off' });
88
+ const look = (name: string) => {
89
+ const node = key(name);
90
+ const [, bar] = node.props.children;
91
+ return { key: flatten(node.props.style), bar: flatten(bar.props.style), said: node.props.accessibilityValue.text };
92
+ };
93
+ const off = look('Control');
94
+ const latched = look('Shift');
95
+ const locked = look('Alt');
96
+ expect([off.said, latched.said, locked.said]).toEqual(['off', 'latched', 'locked']);
97
+ expect(off.key.backgroundColor).not.toBe(latched.key.backgroundColor);
98
+ // Latched and locked share a fill and differ in shape: the rim and the bar.
99
+ expect(locked.key.borderColor).not.toBe(latched.key.borderColor);
100
+ expect(locked.bar.backgroundColor).not.toBe(latched.bar.backgroundColor);
101
+ expect(key('Control').props.accessibilityState).toEqual({ selected: false });
102
+ expect(key('Shift').props.accessibilityState).toEqual({ selected: true });
103
+ });
104
+
105
+ it('hands a tap to the session', () => {
106
+ const { key, onTap } = render();
107
+ key('Control').props.onPress();
108
+ expect(onTap).toHaveBeenCalledWith('Control');
109
+ });
110
+
111
+ it('keeps every label at 4.5:1 or better', () => {
112
+ expect(contrast(KEY_COLORS.offLabel, KEY_COLORS.off)).toBeGreaterThanOrEqual(4.5);
113
+ expect(contrast(KEY_COLORS.onLabel, KEY_COLORS.on)).toBeGreaterThanOrEqual(4.5);
114
+ });
115
+ });
116
+
117
+ describe('ClipboardConfirmation', () => {
118
+ beforeEach(() => { vi.useFakeTimers(); });
119
+ afterEach(() => { vi.useRealTimers(); });
120
+
121
+ const transfer = (id: number, text = 'hello world'): ClipboardTransfer => ({ id, direction: 'to-desktop', text, truncated: false });
122
+
123
+ it('shows each transfer, ignores touches, and goes away after about 1.5 s', () => {
124
+ let tree!: TestRenderer.ReactTestRenderer;
125
+ act(() => { tree = TestRenderer.create(<ClipboardConfirmation transfer={null} />); });
126
+ expect(tree.toJSON()).toBeNull();
127
+
128
+ act(() => { tree.update(<ClipboardConfirmation transfer={transfer(1)} />); });
129
+ const place = tree.toJSON() as TestRenderer.ReactTestRendererJSON;
130
+ expect(place.props.pointerEvents).toBe('none');
131
+ expect(flatten(place.props.style).position).toBeUndefined();
132
+ expect(JSON.stringify(place)).toContain('Sent to desktop · “hello world”');
133
+
134
+ act(() => { vi.advanceTimersByTime(CLIPBOARD_CONFIRMATION_MS - 1); });
135
+ expect(tree.toJSON()).not.toBeNull();
136
+ act(() => { vi.advanceTimersByTime(1); });
137
+ expect(tree.toJSON()).toBeNull();
138
+
139
+ // The same text again is a new transfer and is confirmed again.
140
+ act(() => { tree.update(<ClipboardConfirmation transfer={transfer(2)} />); });
141
+ expect(tree.toJSON()).not.toBeNull();
142
+ });
143
+
144
+ it('describes where the text went and how it starts, with no byte count', () => {
145
+ expect(describeTransfer({ id: 1, direction: 'to-phone', text: 'a\n b', truncated: false })).toBe('Copied to phone · “a b”');
146
+ expect(describeTransfer({ id: 1, direction: 'to-phone', text: 'x'.repeat(2048), truncated: true })).toBe(`Copied to phone · “${'x'.repeat(24)}…”`);
147
+ expect(describeTransfer({ id: 1, direction: 'to-desktop', text: ' ', truncated: false })).toBe('Sent to desktop');
148
+ });
149
+
150
+ it('reads at 4.5:1 or better over a white or a black desktop', () => {
151
+ expect(contrast(PILL_COLORS.text, PILL_COLORS.backing, '#ffffff')).toBeGreaterThanOrEqual(4.5);
152
+ expect(contrast(PILL_COLORS.text, PILL_COLORS.backing, '#000000')).toBeGreaterThanOrEqual(4.5);
153
+ });
154
+ });
155
+
156
+ describe('example Copy', () => {
157
+ it('writes only on a tap and retains a backed accessible failure until the next tap', async () => {
158
+ example.write.mockReset();
159
+ example.copy.mockReset();
160
+ example.copy.mockImplementation(async (write: (text: string) => Promise<void>) => { await write('desktop text'); });
161
+ let tree!: TestRenderer.ReactTestRenderer;
162
+ await act(async () => { tree = TestRenderer.create(<App />); });
163
+ expect(example.write).not.toHaveBeenCalled();
164
+ const tap = () => tree.root.findByProps({ testID: 'desklink-copy' }).props.onPress();
165
+
166
+ example.copy.mockRejectedValueOnce(new Error('clipboard is unavailable for this desktop source'));
167
+ await act(async () => { tap(); });
168
+ expect(tree.root.findByProps({ testID: 'desklink-copy-error' }).findByType('Text').props.children)
169
+ .toBe('Clipboard failed: clipboard is unavailable for this desktop source');
170
+ expect(example.write).not.toHaveBeenCalled();
171
+
172
+ example.write.mockResolvedValue(false);
173
+ await act(async () => { tap(); });
174
+ expect(example.write).toHaveBeenCalledWith('desktop text');
175
+ const error = tree.root.findByProps({ testID: 'desklink-copy-error' });
176
+ expect(error.props.accessible).toBe(true);
177
+ expect(error.props.accessibilityRole).toBe('alert');
178
+ expect(error.props.accessibilityLiveRegion).toBe('polite');
179
+ const style = flatten(error.props.style);
180
+ expect(style.paddingHorizontal).toBeGreaterThan(0);
181
+ expect(style.paddingVertical).toBeGreaterThan(0);
182
+ expect(contrast('#ffffff', style.backgroundColor as string)).toBeGreaterThanOrEqual(4.5);
183
+ expect(error.findByType('Text').props.children).toBe('Clipboard failed: The phone refused the clipboard write.');
184
+ expect(tree.root.findAllByProps({ testID: 'desklink-clipboard' })).toHaveLength(0);
185
+
186
+ vi.useFakeTimers();
187
+ try {
188
+ act(() => { vi.advanceTimersByTime(5000); });
189
+ expect(tree.root.findAllByProps({ testID: 'desklink-copy-error' })).toHaveLength(1);
190
+ } finally { vi.useRealTimers(); }
191
+
192
+ example.write.mockRejectedValue(new Error('native write refused'));
193
+ await act(async () => { tap(); });
194
+ expect(tree.root.findByProps({ testID: 'desklink-copy-error' }).findByType('Text').props.children).toBe('Clipboard failed: native write refused');
195
+
196
+ let finish!: (written: boolean) => void;
197
+ example.write.mockImplementation(() => new Promise<boolean>((resolve) => { finish = resolve; }));
198
+ await act(async () => { tap(); });
199
+ expect(tree.root.findAllByProps({ testID: 'desklink-copy-error' })).toHaveLength(0);
200
+ expect(tree.root.findAllByProps({ testID: 'desklink-clipboard' })).toHaveLength(0);
201
+ await act(async () => { finish(true); });
202
+ expect(tree.root.findAllByProps({ testID: 'desklink-copy-error' })).toHaveLength(0);
203
+ act(() => { tree.unmount(); });
204
+ });
205
+ });
package/src/index.ts CHANGED
@@ -10,12 +10,15 @@ export * from './protocol';
10
10
  */
11
11
  export type { NativeDesklinkModule, NativeSessionEvent } from './native';
12
12
  export { desktopAvailable } from './availability';
13
- export { DesktopView, type DesktopViewProps } from './DesktopView';
13
+ export { DesktopView, type DesktopViewProps, type EdgeInsets } from './DesktopView';
14
14
  export { observeWebKeyboardMotion } from './webKeyboardMotion';
15
15
  export type { StickyModifier, StickyModifiers, StickyState } from './stickyModifiers';
16
+ export { ModifierKeys, type ModifierKeysProps } from './ModifierKeys';
17
+ export { ClipboardConfirmation, type ClipboardConfirmationProps } from './ClipboardConfirmation';
16
18
  export {
17
19
  useDesktopSession,
18
20
  CONTROL_PERMISSIONS,
21
+ type ClipboardTransfer,
19
22
  type DesktopSession,
20
23
  type DesktopSessionOptions,
21
24
  } from './useDesktopSession';
package/src/native.ios.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { MediaStream, RTCPeerConnection } from 'react-native-webrtc';
2
2
  import type { NativeDesklinkModule, NativeSessionEvent } from './native';
3
3
  import { requireWebRTC } from './webrtc.ios';
4
+ import { videoStats } from './videoStats';
4
5
 
5
6
  /** The WebRTC stack, loaded the first time a session opens rather than with this module. */
6
7
  function loadWebRTC(): { RTCPeerConnection: new (config: unknown) => RTCPeerConnection; MediaStream: new () => MediaStream } | null {
@@ -99,6 +100,10 @@ function failure(id: string, error: unknown) {
99
100
  }
100
101
 
101
102
  export const nativeDesklink: NativeDesklinkModule = {
103
+ async getStats(id) {
104
+ const session = sessions.get(id);
105
+ return session ? videoStats(await session.peer.getStats()) : '[]';
106
+ },
102
107
  createSession(iceServersJson) {
103
108
  const id = `desklink-${++nextId}`;
104
109
  try {
package/src/native.ts CHANGED
@@ -45,6 +45,8 @@ export interface NativeDesklinkModule {
45
45
  /** Hold the screen in landscape while the desktop is shown, or follow the phone again. */
46
46
  setOrientation(mode: 'landscape' | 'auto'): boolean;
47
47
  closeSession(id: string): boolean;
48
+ /** Video reception and codec statistics, excluding network addresses and credentials. */
49
+ getStats?(id: string): Promise<string>;
48
50
  isAvailable(): boolean;
49
51
  addListener?(name: 'onSessionEvent', handler: (event: NativeSessionEvent) => void): { remove: () => void };
50
52
  }
@@ -57,4 +59,3 @@ export const nativeDesklink: NativeDesklinkModule | null = native;
57
59
 
58
60
  /** True when this build can show a live desktop surface at all. */
59
61
  export const desktopAvailable = native !== null;
60
-
package/src/native.web.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { NativeDesklinkModule, NativeEventName, NativeSessionEvent } from './native';
2
+ import { videoStats } from './videoStats';
2
3
  import type { GestureProfile } from './protocol';
3
4
  import { observeWebKeyboardMotion } from './webKeyboardMotion';
4
5
 
@@ -898,6 +899,10 @@ const sequence = new Map<string, number>();
898
899
  export const desktopAvailable = true;
899
900
 
900
901
  export const nativeDesklink: NativeDesklinkModule = {
902
+ async getStats(id) {
903
+ const session = sessions.get(id);
904
+ return session ? videoStats(await session.peer.getStats()) : '[]';
905
+ },
901
906
  isAvailable: () => true,
902
907
 
903
908
  createSession(iceServersJson: string): string | null {
package/src/protocol.ts CHANGED
@@ -59,7 +59,7 @@ export interface SessionOpenRequest {
59
59
  }
60
60
 
61
61
  export interface SessionOpenResult {
62
- cursor?: { mode: 'embedded' | 'hidden' | 'metadata' | 'unavailable'; positions: boolean };
62
+ cursor?: { mode: 'embedded' | 'hidden' | 'metadata' | 'unavailable'; positions: boolean; source?: 'ext-image-copy-capture-v1'; limitation?: string };
63
63
  sessionId: string;
64
64
  generation: number;
65
65
  source: { kind: string; width: number; height: number; origin: { x: number; y: number } };
@@ -83,6 +83,7 @@ export interface CursorSample {
83
83
  y: number;
84
84
  visible: boolean;
85
85
  timestamp_us: number;
86
+ hotspot?: { x: number; y: number };
86
87
  }
87
88
 
88
89
  /**
@@ -25,6 +25,14 @@ import {
25
25
  type StickyModifiers,
26
26
  } from './stickyModifiers';
27
27
 
28
+ /** One clipboard transfer that reached its destination; `id` tells two equal transfers apart. */
29
+ export interface ClipboardTransfer {
30
+ id: number;
31
+ direction: 'to-phone' | 'to-desktop';
32
+ text: string;
33
+ truncated: boolean;
34
+ }
35
+
28
36
  /** How long a clipboard round trip may take before it is reported as lost. */
29
37
  const CLIPBOARD_TIMEOUT_MS = 4000;
30
38
 
@@ -191,14 +199,16 @@ export interface DesktopSessionOptions {
191
199
 
192
200
  export interface DesktopSession {
193
201
  snapshot: SessionSnapshot;
202
+ /** Video reception and codec diagnostics, without network addresses. */
203
+ getStats: () => Promise<Array<Record<string, unknown>>>;
194
204
  /** The native session handle, or null when nothing is open. */
195
205
  nativeId: string | null;
196
206
  connect: () => Promise<void>;
197
207
  close: (reason?: string) => Promise<void>;
198
208
  showKeyboard: () => void;
199
209
  hideKeyboard: () => void;
200
- /** Copy the desktop's clipboard to the phone; the caller places it locally. */
201
- copyRemoteToLocal: () => Promise<{ text: string; truncated: boolean }>;
210
+ /** Copy using the caller's local writer, which must reject if the write fails. */
211
+ copyRemoteToLocal: (writeLocal: (text: string) => Promise<void>) => Promise<{ text: string; truncated: boolean }>;
202
212
  /** Send the phone's clipboard text to the desktop. */
203
213
  pasteLocalToRemote: (text: string) => Promise<void>;
204
214
  /** Release anything the desktop is holding, without ending the session. */
@@ -219,6 +229,8 @@ export interface DesktopSession {
219
229
  * because the engine lets go of exactly the modifiers the message names.
220
230
  */
221
231
  pressKey: (name: string) => () => void;
232
+ /** The last clipboard transfer that completed, for the app to confirm; null until one does. */
233
+ clipboard: ClipboardTransfer | null;
222
234
  }
223
235
 
224
236
  const IDLE: SessionSnapshot = {
@@ -243,6 +255,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
243
255
  const [modifiers, setModifiers] = useState<StickyModifiers>(NO_MODIFIERS);
244
256
  /** Read by input as it arrives, which can be faster than a render. */
245
257
  const modifiersRef = useRef<StickyModifiers>(NO_MODIFIERS);
258
+ const [clipboard, setClipboard] = useState<ClipboardTransfer | null>(null);
259
+ const clipboardCount = useRef(0);
246
260
 
247
261
  const opened = useRef<SessionOpenResult | null>(null);
248
262
  const signaling = useRef<Signaling | null>(null);
@@ -556,6 +570,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
556
570
  if (id != null) nativeDesklink?.hideKeyboard(id);
557
571
  }, []);
558
572
 
573
+ const getStats = useCallback(async (): Promise<Array<Record<string, unknown>>> => {
574
+ const id = nativeRef.current;
575
+ if (id == null || !nativeDesklink?.getStats) return [];
576
+ return JSON.parse(await nativeDesklink.getStats(id));
577
+ }, []);
578
+
559
579
  const fitToView = useCallback(() => {
560
580
  const id = nativeRef.current;
561
581
  if (id != null) nativeDesklink?.fitToView(id);
@@ -879,6 +899,14 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
879
899
  return;
880
900
  }
881
901
  if (reply.kind === 'rejected') {
902
+ // These session-wide clipboard refusals have no request ID.
903
+ // A generic permission rejection can belong to other input.
904
+ if (reply.seq === 0 && (reply.code === 'clipboard-unsupported' || reply.code === 'session')) {
905
+ for (const resolve of pendingClipboard.current.values()) {
906
+ resolve({ text: '', truncated: false, error: reply.message });
907
+ }
908
+ pendingClipboard.current.clear();
909
+ }
882
910
  diagnostics.current.lastRejection = `${reply.code}: ${reply.message}`;
883
911
  update({ diagnostics: { ...diagnostics.current } });
884
912
  optionsRef.current.onRejected?.({ code: reply.code, message: reply.message });
@@ -929,7 +957,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
929
957
  };
930
958
  }, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText]);
931
959
 
932
- const copyRemoteToLocal = useCallback(async () => {
960
+ const copyRemoteToLocal = useCallback(async (writeLocal: (text: string) => Promise<void>) => {
933
961
  if (!inputEnabled.current) throw new Error('Desktop control is off.');
934
962
  const id = nativeRef.current;
935
963
  if (id == null || nativeDesklink == null) throw new Error('No desktop session is open.');
@@ -943,6 +971,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
943
971
  nativeDesklink.sendControl(id, JSON.stringify({ kind: 'clipboard_read', request }));
944
972
  const reply = await answer;
945
973
  if (reply.error != null) throw new Error(reply.error);
974
+ await writeLocal(reply.text);
975
+ setClipboard({ id: ++clipboardCount.current, direction: 'to-phone', text: reply.text, truncated: reply.truncated });
946
976
  return { text: reply.text, truncated: reply.truncated };
947
977
  }, []);
948
978
 
@@ -960,6 +990,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
960
990
  nativeDesklink.sendControl(id, JSON.stringify({ kind: 'clipboard_write', request, text }));
961
991
  const reply = await answer;
962
992
  if (reply.error != null) throw new Error(reply.error);
993
+ setClipboard({ id: ++clipboardCount.current, direction: 'to-desktop', text, truncated: false });
963
994
  }, []);
964
995
 
965
996
  const close = useCallback(async (reason = 'closed by the user') => {
@@ -1072,11 +1103,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1072
1103
  releaseHeld,
1073
1104
  setInputEnabled,
1074
1105
  fitToView,
1106
+ getStats,
1075
1107
  setOrientation,
1076
1108
  send,
1077
1109
  modifiers,
1078
1110
  tapModifier,
1079
1111
  pressKey,
1112
+ clipboard,
1080
1113
  }), [
1081
1114
  snapshot,
1082
1115
  nativeId,
@@ -1089,11 +1122,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1089
1122
  releaseHeld,
1090
1123
  setInputEnabled,
1091
1124
  fitToView,
1125
+ getStats,
1092
1126
  setOrientation,
1093
1127
  send,
1094
1128
  modifiers,
1095
1129
  tapModifier,
1096
1130
  pressKey,
1131
+ clipboard,
1097
1132
  ]);
1098
1133
  }
1099
1134
 
@@ -0,0 +1,16 @@
1
+ /** Keep receiver diagnostics useful without disclosing ICE addresses or credentials. */
2
+ export function videoStats(report: { forEach(callback: (stat: Record<string, unknown>) => void): void }): string {
3
+ const fields = new Set([
4
+ 'id', 'type', 'timestamp', 'kind', 'mediaType', 'codecId', 'mimeType', 'clockRate', 'payloadType',
5
+ 'packetsReceived', 'packetsLost', 'bytesReceived', 'framesReceived', 'framesDecoded',
6
+ 'keyFramesDecoded', 'framesDropped', 'framesPerSecond', 'frameWidth', 'frameHeight',
7
+ 'nackCount', 'pliCount', 'firCount', 'totalDecodeTime', 'decoderImplementation', 'jitterBufferDelay',
8
+ ]);
9
+ const stats: Array<Record<string, unknown>> = [];
10
+ report.forEach((stat) => {
11
+ if (stat.type === 'inbound-rtp' || stat.type === 'codec') {
12
+ stats.push(Object.fromEntries(Object.entries(stat).filter(([key]) => fields.has(key))));
13
+ }
14
+ });
15
+ return JSON.stringify(stats);
16
+ }