@desklink/react-native 0.1.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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The wire contract this client speaks, in one place.
3
+ *
4
+ * Two layers, deliberately separate:
5
+ *
6
+ * - **The engine's local control protocol** (lifecycle, SDP/ICE, clipboard,
7
+ * metrics). An application carries it over whatever authenticated channel it
8
+ * already has; this package never assumes a transport.
9
+ * - **The session's control channel** (pointer, wheel, keys, text), which rides
10
+ * the WebRTC data channel the engine creates. It belongs to the authorized
11
+ * media session, so revoking the session closes it.
12
+ *
13
+ * `docs/PROTOCOL.md` in the host engine package is the authority; these types
14
+ * only describe the subset a client needs.
15
+ */
16
+
17
+ export const PROTOCOL_VERSION = 2;
18
+
19
+ export type Permission = 'view' | 'control' | 'clipboard';
20
+
21
+ export interface IceServerConfig {
22
+ urls: string[];
23
+ username?: string;
24
+ credential?: string;
25
+ }
26
+
27
+ /** Geometry of the surface, as the engine reports it after capture starts. */
28
+ export interface SurfaceGeometry {
29
+ source: { width: number; height: number };
30
+ encoded: { width: number; height: number };
31
+ /** Origin of the captured source inside the desktop's own layout. */
32
+ origin: { x: number; y: number };
33
+ }
34
+
35
+ export interface SessionOpenRequest {
36
+ permissions: Permission[];
37
+ maxWidth?: number;
38
+ maxHeight?: number;
39
+ bitrateKbps?: number;
40
+ maxFps?: number;
41
+ iceServers?: IceServerConfig[];
42
+ restoreToken?: string;
43
+ ttlSeconds?: number;
44
+ }
45
+
46
+ export interface SessionOpenResult {
47
+ sessionId: string;
48
+ generation: number;
49
+ source: { kind: string; width: number; height: number; origin: { x: number; y: number } };
50
+ geometry: SurfaceGeometry;
51
+ }
52
+
53
+ export interface RtcDescription {
54
+ type: 'offer' | 'answer';
55
+ sdp: string;
56
+ }
57
+
58
+ export interface RtcCandidate {
59
+ candidate: string;
60
+ sdpMid?: string | null;
61
+ sdpMLineIndex?: number | null;
62
+ }
63
+
64
+ /** Engine → client lifecycle events, already unwrapped from the local protocol. */
65
+ export type SessionEvent =
66
+ | { kind: 'description'; description: RtcDescription }
67
+ | { kind: 'candidate'; candidate: RtcCandidate }
68
+ | { kind: 'state'; capture: string; transport: string; firstFrame: boolean }
69
+ | { kind: 'restoreToken'; token: string }
70
+ | { kind: 'revoked'; reason: string };
71
+
72
+ /**
73
+ * The application's own authenticated channel to the host engine.
74
+ *
75
+ * The package calls these and nothing else, so an application can carry them
76
+ * over its existing encrypted request path, a socket, or an in-process bridge.
77
+ * `subscribe` delivers engine notifications until the returned function runs.
78
+ */
79
+ export interface Signaling {
80
+ /**
81
+ * Send one request. A rejection may carry a stable `code` — the engine's or
82
+ * the host's own token — which the package reads to classify a refusal, so a
83
+ * refusal is not retried as though it were a network failure.
84
+ */
85
+ request<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
86
+ subscribe(handler: (event: SessionEvent) => void): () => void;
87
+ }
88
+
89
+ export type SessionStatus =
90
+ | 'idle'
91
+ | 'opening'
92
+ | 'connecting'
93
+ | 'live'
94
+ | 'reconnecting'
95
+ | 'ended'
96
+ | 'failed';
97
+
98
+ export type SessionFailure =
99
+ | { code: 'permission'; message: string }
100
+ /** Nobody approved the desktop's own screen-sharing prompt in time. */
101
+ | { code: 'consent'; message: string }
102
+ /** The computer has no screen at all and cannot start one (a server without Xvfb). */
103
+ | { code: 'no-screen'; message: string }
104
+ | { code: 'unsupported-codec'; message: string }
105
+ | { code: 'incompatible-version'; message: string }
106
+ | { code: 'source-changed'; message: string }
107
+ | { code: 'revoked'; message: string }
108
+ | { code: 'transport'; message: string }
109
+ | { code: 'input-unavailable'; message: string }
110
+ | { code: 'platform'; message: string };
111
+
112
+ export interface SessionSnapshot {
113
+ status: SessionStatus;
114
+ /** Geometry is only known once the engine has a consented capture stream. */
115
+ geometry: SurfaceGeometry | null;
116
+ /** True once an authorized, current-generation frame has actually rendered. */
117
+ presented: boolean;
118
+ failure: SessionFailure | null;
119
+ /** Redacted diagnostics: codec, route, counters. Never identities. */
120
+ diagnostics: Record<string, string | number | boolean>;
121
+ }
122
+
123
+ /**
124
+ * Control-channel messages. `seq` is monotonic per generation; the engine
125
+ * refuses a repeat, so a replayed gesture cannot move the pointer twice.
126
+ */
127
+ export type ControlMessage =
128
+ | { kind: 'pointer'; phase: 'move' | 'down' | 'up' | 'cancel'; x: number; y: number; button?: number }
129
+ /** Detents; a fraction is a smooth partial scroll where the desktop supports it. */
130
+ | { kind: 'wheel'; dx: number; dy: number }
131
+ | { kind: 'key'; name?: string; character?: string; down: boolean; modifiers?: string[] }
132
+ | { kind: 'text'; text: string }
133
+ | { kind: 'release_all' }
134
+ | { kind: 'clipboard_read'; request: string }
135
+ | { kind: 'clipboard_write'; request: string; text: string };
136
+
137
+ export type ControlReply =
138
+ | { kind: 'hello'; protocol: number; geometry: SurfaceGeometry }
139
+ | { kind: 'ack'; seq: number }
140
+ | { kind: 'rejected'; seq: number; code: string; message: string }
141
+ | { kind: 'clipboard'; request: string; text: string; truncated?: boolean; error?: string }
142
+ | { kind: 'revoked'; reason: string };
143
+
144
+ export function parseControlReply(raw: string): ControlReply | null {
145
+ try {
146
+ const parsed = JSON.parse(raw) as ControlReply;
147
+ return typeof parsed?.kind === 'string' ? parsed : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
@@ -0,0 +1,66 @@
1
+ import type { ControlMessage } from './protocol';
2
+
3
+ /**
4
+ * Ctrl and Shift for a touch keyboard, which has neither.
5
+ *
6
+ * The cycle is the terminal key row's: one tap arms a modifier for the next
7
+ * key, a second tap locks it, a third lets it go. An armed modifier travels
8
+ * with the key it modifies, as that key's own chord, so the desktop never holds
9
+ * a modifier on its own and nothing can be left pressed between keys.
10
+ */
11
+ export type StickyModifier = 'Control' | 'Shift';
12
+ export type StickyState = 'off' | 'once' | 'lock';
13
+ export type StickyModifiers = Readonly<Record<StickyModifier, StickyState>>;
14
+
15
+ export const NO_MODIFIERS: StickyModifiers = { Control: 'off', Shift: 'off' };
16
+
17
+ const AFTER_TAP: Record<StickyState, StickyState> = { off: 'once', once: 'lock', lock: 'off' };
18
+
19
+ export function tapModifier(current: StickyModifiers, name: StickyModifier): StickyModifiers {
20
+ return { ...current, [name]: AFTER_TAP[current[name]] };
21
+ }
22
+
23
+ /** The modifiers the next key carries, in the engine's names. */
24
+ export function armedModifiers(current: StickyModifiers): StickyModifier[] {
25
+ return (['Control', 'Shift'] as const).filter((name) => current[name] !== 'off');
26
+ }
27
+
28
+ /** What stays armed once a key has used the modifiers: a lock stays, the rest let go. */
29
+ export function afterKey(current: StickyModifiers): StickyModifiers {
30
+ return {
31
+ Control: current.Control === 'lock' ? 'lock' : 'off',
32
+ Shift: current.Shift === 'lock' ? 'lock' : 'off',
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Typed text with modifiers armed: each character is a chord until the armed
38
+ * modifiers are spent, and whatever follows is plain text.
39
+ *
40
+ * A chord names a key, not a case. The phone's keyboard capitalises on its own,
41
+ * and Ctrl with its "V" must still be Ctrl+V, not Ctrl+Shift+V, so a letter is
42
+ * sent lower-case and Shift is pressed only when Shift itself is armed.
43
+ */
44
+ export function typeWithModifiers(
45
+ text: string,
46
+ current: StickyModifiers,
47
+ ): { messages: ControlMessage[]; next: StickyModifiers } {
48
+ const messages: ControlMessage[] = [];
49
+ let next = current;
50
+ let plain = '';
51
+ for (const character of text) {
52
+ const modifiers = armedModifiers(next);
53
+ if (modifiers.length === 0) {
54
+ plain += character;
55
+ continue;
56
+ }
57
+ const key = /^[A-Za-z]$/.test(character) ? character.toLowerCase() : character;
58
+ messages.push(
59
+ { kind: 'key', character: key, modifiers, down: true },
60
+ { kind: 'key', character: key, modifiers, down: false },
61
+ );
62
+ next = afterKey(next);
63
+ }
64
+ if (plain !== '') messages.push({ kind: 'text', text: plain });
65
+ return { messages, next };
66
+ }