@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.
- package/README.md +81 -13
- package/android/build.gradle +13 -1
- package/android/src/main/java/expo/modules/desklink/DesklinkModule.kt +15 -0
- package/android/src/main/java/expo/modules/desklink/DesktopSession.kt +27 -0
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +77 -16
- package/package.json +1 -1
- package/src/ClipboardConfirmation.tsx +57 -0
- package/src/DesktopView.ios.spec.tsx +171 -6
- package/src/DesktopView.ios.tsx +65 -20
- package/src/DesktopView.tsx +23 -2
- package/src/DesktopView.web.tsx +12 -0
- package/src/ModifierKeys.tsx +77 -0
- package/src/controls.spec.tsx +205 -0
- package/src/index.ts +4 -1
- package/src/native.ios.ts +5 -0
- package/src/native.ts +2 -1
- package/src/native.web.ts +5 -0
- package/src/protocol.ts +2 -1
- package/src/useDesktopSession.ts +38 -3
- package/src/videoStats.ts +16 -0
|
@@ -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
|
/**
|
package/src/useDesktopSession.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|