ai-remote 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.
Files changed (114) hide show
  1. package/README.md +50 -0
  2. package/dist/index.js +7 -0
  3. package/dist/index.js.map +7 -0
  4. package/dist/protocols/index.js +35 -0
  5. package/dist/protocols/index.js.map +7 -0
  6. package/dist/protocols/rdp/buffer.js +170 -0
  7. package/dist/protocols/rdp/buffer.js.map +7 -0
  8. package/dist/protocols/rdp/caps.js +260 -0
  9. package/dist/protocols/rdp/caps.js.map +7 -0
  10. package/dist/protocols/rdp/cert.js +135 -0
  11. package/dist/protocols/rdp/cert.js.map +7 -0
  12. package/dist/protocols/rdp/client.js +1068 -0
  13. package/dist/protocols/rdp/client.js.map +7 -0
  14. package/dist/protocols/rdp/cliprdr.js +240 -0
  15. package/dist/protocols/rdp/cliprdr.js.map +7 -0
  16. package/dist/protocols/rdp/credssp.js +254 -0
  17. package/dist/protocols/rdp/credssp.js.map +7 -0
  18. package/dist/protocols/rdp/crypto.js +369 -0
  19. package/dist/protocols/rdp/crypto.js.map +7 -0
  20. package/dist/protocols/rdp/display.js +589 -0
  21. package/dist/protocols/rdp/display.js.map +7 -0
  22. package/dist/protocols/rdp/gcc.js +132 -0
  23. package/dist/protocols/rdp/gcc.js.map +7 -0
  24. package/dist/protocols/rdp/keymap.js +132 -0
  25. package/dist/protocols/rdp/keymap.js.map +7 -0
  26. package/dist/protocols/rdp/mcs.js +223 -0
  27. package/dist/protocols/rdp/mcs.js.map +7 -0
  28. package/dist/protocols/rdp/ntlm.js +305 -0
  29. package/dist/protocols/rdp/ntlm.js.map +7 -0
  30. package/dist/protocols/rdp/pdu.js +444 -0
  31. package/dist/protocols/rdp/pdu.js.map +7 -0
  32. package/dist/protocols/rdp/rail.js +383 -0
  33. package/dist/protocols/rdp/rail.js.map +7 -0
  34. package/dist/protocols/rdp/rle.js +276 -0
  35. package/dist/protocols/rdp/rle.js.map +7 -0
  36. package/dist/protocols/rdp/sec.js +156 -0
  37. package/dist/protocols/rdp/sec.js.map +7 -0
  38. package/dist/protocols/rdp/session.js +307 -0
  39. package/dist/protocols/rdp/session.js.map +7 -0
  40. package/dist/protocols/rdp/tls.js +354 -0
  41. package/dist/protocols/rdp/tls.js.map +7 -0
  42. package/dist/protocols/rdp/vchannel.js +59 -0
  43. package/dist/protocols/rdp/vchannel.js.map +7 -0
  44. package/dist/protocols/rdp/x224.js +110 -0
  45. package/dist/protocols/rdp/x224.js.map +7 -0
  46. package/dist/protocols/ssh/kex.js +177 -0
  47. package/dist/protocols/ssh/kex.js.map +7 -0
  48. package/dist/protocols/ssh/messages.js +55 -0
  49. package/dist/protocols/ssh/messages.js.map +7 -0
  50. package/dist/protocols/ssh/session.js +608 -0
  51. package/dist/protocols/ssh/session.js.map +7 -0
  52. package/dist/protocols/ssh/terminal.js +191 -0
  53. package/dist/protocols/ssh/terminal.js.map +7 -0
  54. package/dist/protocols/ssh/transport.js +611 -0
  55. package/dist/protocols/ssh/transport.js.map +7 -0
  56. package/dist/protocols/ssh/wire.js +189 -0
  57. package/dist/protocols/ssh/wire.js.map +7 -0
  58. package/dist/protocols/types.js +1 -0
  59. package/dist/protocols/types.js.map +7 -0
  60. package/dist/protocols/vnc/input.js +16 -0
  61. package/dist/protocols/vnc/input.js.map +7 -0
  62. package/dist/protocols/vnc/session.js +259 -0
  63. package/dist/protocols/vnc/session.js.map +7 -0
  64. package/dist/shared/connection.js +114 -0
  65. package/dist/shared/connection.js.map +7 -0
  66. package/dist/shared/device.js +1 -0
  67. package/dist/shared/device.js.map +7 -0
  68. package/dist/shared/hosts.js +133 -0
  69. package/dist/shared/hosts.js.map +7 -0
  70. package/dist/shared/icons.js +42 -0
  71. package/dist/shared/icons.js.map +7 -0
  72. package/dist/shared/protocol.js +40 -0
  73. package/dist/shared/protocol.js.map +7 -0
  74. package/dist/shared/signals.js +42 -0
  75. package/dist/shared/signals.js.map +7 -0
  76. package/package.json +82 -0
  77. package/src/index.ts +14 -0
  78. package/src/protocols/index.ts +72 -0
  79. package/src/protocols/rdp/buffer.ts +204 -0
  80. package/src/protocols/rdp/caps.ts +304 -0
  81. package/src/protocols/rdp/cert.ts +190 -0
  82. package/src/protocols/rdp/client.ts +1363 -0
  83. package/src/protocols/rdp/cliprdr.ts +344 -0
  84. package/src/protocols/rdp/credssp.ts +319 -0
  85. package/src/protocols/rdp/crypto.ts +416 -0
  86. package/src/protocols/rdp/display.ts +750 -0
  87. package/src/protocols/rdp/gcc.ts +162 -0
  88. package/src/protocols/rdp/keymap.ts +68 -0
  89. package/src/protocols/rdp/mcs.ts +283 -0
  90. package/src/protocols/rdp/ntlm.ts +380 -0
  91. package/src/protocols/rdp/pdu.ts +497 -0
  92. package/src/protocols/rdp/rail.ts +480 -0
  93. package/src/protocols/rdp/rle.ts +343 -0
  94. package/src/protocols/rdp/sec.ts +184 -0
  95. package/src/protocols/rdp/session.ts +393 -0
  96. package/src/protocols/rdp/tls.ts +452 -0
  97. package/src/protocols/rdp/vchannel.ts +89 -0
  98. package/src/protocols/rdp/x224.ts +139 -0
  99. package/src/protocols/ssh/kex.ts +227 -0
  100. package/src/protocols/ssh/messages.ts +54 -0
  101. package/src/protocols/ssh/session.ts +878 -0
  102. package/src/protocols/ssh/terminal.ts +242 -0
  103. package/src/protocols/ssh/transport.ts +732 -0
  104. package/src/protocols/ssh/wire.ts +220 -0
  105. package/src/protocols/types.ts +146 -0
  106. package/src/protocols/vnc/input.ts +13 -0
  107. package/src/protocols/vnc/novnc.d.ts +75 -0
  108. package/src/protocols/vnc/session.ts +367 -0
  109. package/src/shared/connection.ts +206 -0
  110. package/src/shared/device.ts +28 -0
  111. package/src/shared/hosts.ts +240 -0
  112. package/src/shared/icons.ts +49 -0
  113. package/src/shared/protocol.ts +55 -0
  114. package/src/shared/signals.ts +67 -0
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Everything VNC-specific, behind the driver interface the RDP side also
3
+ * implements. noVNC's RFB object does the protocol work; this module decides
4
+ * how it is addressed, constructed, controlled and explained.
5
+ *
6
+ * VNC splits the handshake differently from RDP: noVNC hands authentication to
7
+ * the Worker, because macOS Screen Sharing needs the Apple Diffie-Hellman
8
+ * (type 30) exchange that noVNC does not implement. That is why credentials
9
+ * travel in the URL here and never do for RDP.
10
+ */
11
+
12
+ import RFB from '@novnc/novnc';
13
+ import type {
14
+ DialogCopy,
15
+ DisconnectContext,
16
+ PointerButton,
17
+ ProtocolDriver,
18
+ RemoteSession,
19
+ ScreenGeometry,
20
+ SessionOptions,
21
+ } from '../types';
22
+ import type { ConnectionSettings } from '../../shared/connection';
23
+ import { DEFAULT_PORTS } from '../../shared/protocol';
24
+ import {
25
+ GATEWAY_KEEPALIVE_SIGNAL,
26
+ GATEWAY_READY_SIGNAL,
27
+ KEEPALIVE_INTERVAL_MS,
28
+ } from '../../shared/signals';
29
+ import { vncWheelMasks } from './input';
30
+
31
+ const DIALOG: DialogCopy = {
32
+ usernameLabel: 'Username',
33
+ usernamePlaceholder: 'macOS username (optional)',
34
+ usernameHelp: 'Use your macOS account name. Leave it empty when the VNC server only asks for a password.',
35
+ passwordLabel: 'Password',
36
+ passwordPlaceholder: 'Screen sharing password',
37
+ passwordHelp: 'Use the macOS account password, or the password configured for VNC screen sharing.',
38
+ // A VNC session needs some secret; RDP can sign in on the host's own screen.
39
+ requiresPassword: true,
40
+ };
41
+
42
+ function buildUrl(origin: string, settings: ConnectionSettings): string {
43
+ const query = new URLSearchParams({ host: settings.host, port: String(settings.port) });
44
+ if (settings.username) query.set('vnc_user', settings.username);
45
+ if (settings.password) query.set('vnc_pass', settings.password);
46
+ return `${origin}/ws?${query}`;
47
+ }
48
+
49
+ /** Left = 1, middle = 2, right = 4, as RFB's button mask numbers them. */
50
+ const BUTTON_MASK: Record<PointerButton, number> = { 0: 1, 1: 2, 2: 4 };
51
+
52
+ function eventDetail(event: Event): Record<string, unknown> {
53
+ if (!('detail' in event)) return {};
54
+ const detail = event.detail;
55
+ if (!detail || typeof detail !== 'object') return {};
56
+ const copy: Record<string, unknown> = {};
57
+ for (const [key, value] of Object.entries(detail)) copy[key] = value;
58
+ return copy;
59
+ }
60
+
61
+ /**
62
+ * An RFB object given the control surface the RDP session already has, so the
63
+ * AI pane's screen tool never has to ask which protocol it is driving.
64
+ *
65
+ * This is a wrapper rather than a set of properties bolted onto the RFB
66
+ * instance: assigning methods onto someone else's object works until they add a
67
+ * member of the same name, and the type system cannot help when it happens.
68
+ */
69
+ class VncSession extends EventTarget implements RemoteSession {
70
+ readonly protocol = 'vnc' as const;
71
+ readonly #rfb: RFB;
72
+ readonly #credentials: SessionOptions['credentials'];
73
+ #transport: WebSocket | null = null;
74
+ #keepalive: ReturnType<typeof setInterval> | null = null;
75
+ #readySent = false;
76
+ #close: { code: number; reason: string } | null = null;
77
+
78
+ constructor(container: HTMLElement, url: string, options: SessionOptions) {
79
+ super();
80
+
81
+ this.#credentials = options.credentials;
82
+ this.#rfb = new RFB(container, url, {
83
+ credentials: {
84
+ username: options.credentials.username,
85
+ password: options.credentials.password,
86
+ },
87
+ shared: true,
88
+ wsProtocols: ['binary'],
89
+ });
90
+
91
+ // On touch devices noVNC renders the remote pointer as a fixed canvas on
92
+ // document.body with the highest z-index on the page. Tag that fallback
93
+ // layer so the side panel can hide it; otherwise the Mac's large white
94
+ // pointer stays painted over terminal fields once the panel opens.
95
+ this.#rfb._cursor?._canvas?.classList.add('remote-cursor-overlay');
96
+
97
+ // Some VNC servers temporarily advertise a fully transparent cursor. Keep
98
+ // the normal remote cursor, but let noVNC's small dot fallback show while
99
+ // the advertised one has no visible pixels, so the pointer never vanishes.
100
+ this.#rfb.showDotCursor = true;
101
+
102
+ // Resizing the host to match the browser is right on a laptop and wrong on
103
+ // a phone, where it would turn a Mac desktop into a portrait strip.
104
+ this.#rfb.resizeSession = options.allowRemoteResize;
105
+ this.#rfb.clipViewport = true;
106
+ this.#rfb.scaleViewport = options.scaleViewport;
107
+ this.#rfb.viewOnly = options.viewOnly;
108
+ this.#rfb.qualityLevel = options.quality;
109
+ this.#rfb.compressionLevel = options.compression;
110
+
111
+ // RDP and SSH send these on the socket they own. noVNC owns the VNC
112
+ // socket, so this wrapper has to reach in and do the same: the gateway
113
+ // will not read the host until it sees GATEWAY_READY, and Cloudflare
114
+ // idles out a silent desktop after 100s without keepalives.
115
+ this.#bindTransport();
116
+
117
+ // Re-dispatch rather than have callers listen to two objects.
118
+ for (const name of ['connect', 'disconnect', 'credentialsrequired', 'desktopname', 'clipboard', 'bell'] as const) {
119
+ this.#rfb.addEventListener(name, (event) => {
120
+ if (name === 'credentialsrequired') {
121
+ this.#rfb.sendCredentials({
122
+ username: this.#credentials.username,
123
+ password: this.#credentials.password,
124
+ });
125
+ }
126
+
127
+ const detail = eventDetail(event);
128
+ if (name === 'disconnect' && this.#close) {
129
+ detail.code = this.#close.code;
130
+ detail.reason = this.#close.reason;
131
+ }
132
+ this.dispatchEvent(new CustomEvent(name, { detail }));
133
+ });
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Attach to noVNC's WebSocket once it exists. Construction usually has it
139
+ * already; a retry covers the tick where `_sock._websocket` is still null.
140
+ */
141
+ #bindTransport(): void {
142
+ const attach = (): boolean => {
143
+ const ws = this.#rfb._sock?._websocket ?? null;
144
+ if (!ws || this.#transport === ws) return Boolean(ws);
145
+ this.#transport = ws;
146
+
147
+ const signalReady = () => {
148
+ if (this.#readySent || ws.readyState !== WebSocket.OPEN) return;
149
+ this.#readySent = true;
150
+ ws.send(GATEWAY_READY_SIGNAL);
151
+ };
152
+
153
+ ws.addEventListener('open', signalReady);
154
+ // Capture so the close code is stored before noVNC's `onclose` turns
155
+ // into a `disconnect` event that this wrapper re-dispatches.
156
+ ws.addEventListener('close', (event) => {
157
+ this.#close = { code: event.code, reason: event.reason ?? '' };
158
+ this.#stopKeepalive();
159
+ }, true);
160
+
161
+ this.#keepalive = setInterval(() => {
162
+ if (ws.readyState !== WebSocket.OPEN) return;
163
+ try {
164
+ ws.send(GATEWAY_KEEPALIVE_SIGNAL);
165
+
166
+ // The text frame above stops at the gateway, so it cannot keep the
167
+ // VPC/Tunnel TCP leg alive. A one-pixel incremental request is a
168
+ // normal RFB message, changes no state, and keeps an idle desktop's
169
+ // origin connection active just as RDP and SSH do with their own
170
+ // protocol-level keepalives.
171
+ const sock = this.#rfb._sock;
172
+ if (sock && this.#rfb._rfbConnectionState === 'connected') {
173
+ RFB.messages.fbUpdateRequest(sock, true, 0, 0, 1, 1);
174
+ }
175
+ } catch {
176
+ // A keepalive is not worth breaking a working session over.
177
+ }
178
+ }, KEEPALIVE_INTERVAL_MS);
179
+
180
+ signalReady();
181
+ return true;
182
+ };
183
+
184
+ if (attach()) return;
185
+
186
+ let tries = 0;
187
+ const timer = setInterval(() => {
188
+ if (attach() || ++tries > 20) clearInterval(timer);
189
+ }, 25);
190
+ }
191
+
192
+ #stopKeepalive(): void {
193
+ if (this.#keepalive) clearInterval(this.#keepalive);
194
+ this.#keepalive = null;
195
+ }
196
+
197
+ get connected(): boolean {
198
+ return this.#rfb._rfbConnectionState === 'connected';
199
+ }
200
+
201
+ get socket(): WebSocket | null {
202
+ return this.#rfb._sock?._websocket ?? null;
203
+ }
204
+
205
+ get canvas(): HTMLCanvasElement | null {
206
+ return this.#rfb._canvas;
207
+ }
208
+
209
+ get scaleViewport(): boolean {
210
+ return this.#rfb.scaleViewport;
211
+ }
212
+
213
+ set scaleViewport(enabled: boolean) {
214
+ this.#rfb.scaleViewport = enabled;
215
+ }
216
+
217
+ get viewOnly(): boolean {
218
+ return this.#rfb.viewOnly;
219
+ }
220
+
221
+ set viewOnly(enabled: boolean) {
222
+ this.#rfb.viewOnly = enabled;
223
+ }
224
+
225
+ get qualityLevel(): number {
226
+ return this.#rfb.qualityLevel;
227
+ }
228
+
229
+ set qualityLevel(level: number) {
230
+ this.#rfb.qualityLevel = level;
231
+ }
232
+
233
+ get compressionLevel(): number {
234
+ return this.#rfb.compressionLevel;
235
+ }
236
+
237
+ set compressionLevel(level: number) {
238
+ this.#rfb.compressionLevel = level;
239
+ }
240
+
241
+ get screenGeometry(): ScreenGeometry {
242
+ const width = this.#rfb._fbWidth || 0;
243
+ const height = this.#rfb._fbHeight || 0;
244
+ return { width, height, viewport: { x: 0, y: 0, width, height } };
245
+ }
246
+
247
+ /** Internal state worth logging once the first server frame has been parsed. */
248
+ describeState(): Record<string, unknown> {
249
+ const socket = this.#rfb._sock;
250
+ return {
251
+ connectionState: this.#rfb._rfbConnectionState,
252
+ initializationState: this.#rfb._rfbInitState,
253
+ queuedReceiveBytes: Math.max(0, (socket?._rQlen ?? 0) - (socket?._rQi ?? 0)),
254
+ };
255
+ }
256
+
257
+ disconnect(): void {
258
+ this.#stopKeepalive();
259
+ this.#rfb.disconnect();
260
+ }
261
+
262
+ sendKey(keysym: number, code: string | null, down?: boolean): void {
263
+ this.#rfb.sendKey(keysym, code, down);
264
+ }
265
+
266
+ sendClipboard(text: string): void {
267
+ this.#rfb.clipboardPasteFrom(text);
268
+ }
269
+
270
+ releaseKeyboard(): void {
271
+ // noVNC ungrabs the keyboard when its canvas loses focus.
272
+ this.#rfb.blur();
273
+ }
274
+
275
+ /**
276
+ * noVNC keeps pointer input private -- its own handlers translate browser
277
+ * events into framebuffer coordinates first -- so this reaches for the same
278
+ * wire helper `_sendMouse` ends at, with coordinates already in pixels.
279
+ */
280
+ #pointer(x: number, y: number, mask: number): boolean {
281
+ const socket = this.#rfb._sock;
282
+ if (!socket || this.#rfb._rfbConnectionState !== 'connected' || this.#rfb.viewOnly) return false;
283
+
284
+ const width = this.#rfb._fbWidth || 1;
285
+ const height = this.#rfb._fbHeight || 1;
286
+ RFB.messages.pointerEvent(
287
+ socket,
288
+ Math.max(0, Math.min(width - 1, Math.round(x))),
289
+ Math.max(0, Math.min(height - 1, Math.round(y))),
290
+ mask
291
+ );
292
+ return true;
293
+ }
294
+
295
+ movePointer(x: number, y: number): boolean {
296
+ return this.#pointer(x, y, 0);
297
+ }
298
+
299
+ clickPointer(x: number, y: number, button: PointerButton = 0): boolean {
300
+ if (!this.#pointer(x, y, 0)) return false;
301
+ this.#pointer(x, y, BUTTON_MASK[button]);
302
+ this.#pointer(x, y, 0);
303
+ return true;
304
+ }
305
+
306
+ pointerButton(x: number, y: number, button: PointerButton = 0, pressed = true): boolean {
307
+ return this.#pointer(x, y, pressed ? BUTTON_MASK[button] : 0);
308
+ }
309
+
310
+ scrollPointer(x: number, y: number, delta: { x: number; y: number }): boolean {
311
+ // RFB represents wheels as buttons: 4/5 are up/down and 6/7 are left/right.
312
+ // The tool boundary already limits each axis, but keep the wire path bounded
313
+ // too because other callers may be added later.
314
+ for (const mask of vncWheelMasks(delta)) {
315
+ if (!this.#pointer(x, y, mask)) return false;
316
+ if (!this.#pointer(x, y, 0)) return false;
317
+ }
318
+ return true;
319
+ }
320
+
321
+ /**
322
+ * VNC has no Unicode input message, so text is typed as keysyms. For every
323
+ * character in the Basic Multilingual Plane the keysym is the code point.
324
+ */
325
+ typeText(text: string): boolean {
326
+ if (this.#rfb._rfbConnectionState !== 'connected' || this.#rfb.viewOnly || !text) return false;
327
+
328
+ for (const character of text) {
329
+ const point = character.codePointAt(0) ?? 0;
330
+ const keysym = character === '\n' ? 0xff0d : character === '\t' ? 0xff09 : point;
331
+ this.#rfb.sendKey(keysym, null, true);
332
+ this.#rfb.sendKey(keysym, null, false);
333
+ }
334
+ return true;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Turn a close code and the gateway's reason into something a person can act
340
+ * on. Returns null when nothing VNC-specific applies, leaving the shared text.
341
+ */
342
+ function describeDisconnect({ code, reason, suffix }: DisconnectContext): string | null {
343
+ if (code === 4001 || /auth|password|credential/i.test(reason)) {
344
+ return `VNC authentication failed${suffix}. Verify the macOS account username and its password.`;
345
+ }
346
+ if (code === 4002 || /apple dh/i.test(reason)) {
347
+ return `macOS VNC authentication handshake failed${suffix}.`;
348
+ }
349
+ if (code === 1011 || /cannot connect|unreachable|tcp error|tcp write/i.test(reason)) {
350
+ return `The Worker could not keep a TCP connection to the VNC host${suffix}. `
351
+ + 'Check that Screen Sharing is running and listening on this address.';
352
+ }
353
+ return null;
354
+ }
355
+
356
+ export const vncDriver: ProtocolDriver = {
357
+ protocol: 'vnc',
358
+ label: 'VNC Client',
359
+ defaultPort: DEFAULT_PORTS.vnc,
360
+ dialog: DIALOG,
361
+ buildUrl,
362
+ createSession: (container, url, options) => new VncSession(container, url, options),
363
+ describeState: (session) => (
364
+ session instanceof VncSession ? session.describeState() : {}
365
+ ),
366
+ describeDisconnect,
367
+ };
@@ -0,0 +1,206 @@
1
+ /**
2
+ * One connection, described once.
3
+ *
4
+ * Every way of starting a session -- a saved card on the welcome screen, an
5
+ * address typed by hand, a RemoteApp launcher, a reconnect after a drop --
6
+ * produces exactly this shape and hands it to exactly one dialog. That is the
7
+ * whole point of the type: before it existed, a saved card could only ask for a
8
+ * username and a password, so its port, security layer, domain and picture
9
+ * quality were unreachable once the card had been made.
10
+ */
11
+
12
+ import {
13
+ DEFAULT_PORTS,
14
+ toPort,
15
+ toRdpSecurity,
16
+ toRemoteProtocol,
17
+ type RdpSecurity,
18
+ type RemoteProtocol,
19
+ } from './protocol';
20
+
21
+ /** A published Windows application launched instead of the full desktop. */
22
+ export interface RemoteAppLaunch {
23
+ /** Alias (`||notepad`) or an executable path. */
24
+ program: string;
25
+ workingDir?: string;
26
+ arguments?: string;
27
+ }
28
+
29
+ /** The companion terminal offered beside a desktop. */
30
+ export interface SshCompanion {
31
+ enabled: boolean;
32
+ port: number;
33
+ username: string;
34
+ }
35
+
36
+ /** Picture and input options. VNC negotiates them; RDP and SSH ignore what does not apply. */
37
+ export interface DisplaySettings {
38
+ /** RFB quality level, 1 (smallest) to 9 (sharpest). */
39
+ quality: number;
40
+ /** RFB compression level, 0 (fastest) to 9 (smallest). */
41
+ compression: number;
42
+ /** Fit the remote desktop to the pane rather than scrolling it. */
43
+ scaling: boolean;
44
+ /** Watch without sending keyboard or pointer input. */
45
+ viewOnly: boolean;
46
+ }
47
+
48
+ /**
49
+ * Everything a session needs. `password` is the only field never persisted
50
+ * anywhere -- not in the device library on the gateway, not in localStorage.
51
+ */
52
+ export interface ConnectionSettings {
53
+ /** Stable identity: protocol, address, port and program. See {@link connectionId}. */
54
+ id: string;
55
+ label: string;
56
+
57
+ protocol: RemoteProtocol;
58
+ host: string;
59
+ port: number;
60
+
61
+ username: string;
62
+ password: string;
63
+ /** Windows domain or computer name. RDP only. */
64
+ domain: string;
65
+
66
+ security: RdpSecurity;
67
+ remoteApp: RemoteAppLaunch | null;
68
+
69
+ display: DisplaySettings;
70
+ ssh: SshCompanion;
71
+ /** Open the terminal panel as soon as the desktop appears. */
72
+ openSshOnConnect: boolean;
73
+ }
74
+
75
+ export const DEFAULT_DISPLAY_SETTINGS: DisplaySettings = {
76
+ quality: 6,
77
+ compression: 2,
78
+ scaling: true,
79
+ viewOnly: false,
80
+ };
81
+
82
+ /**
83
+ * A connection's identity is what it points at, not when it was made. Editing
84
+ * the address therefore makes a different connection rather than silently
85
+ * repointing an existing card, which is the behaviour people expect from a
86
+ * list of machines.
87
+ */
88
+ export function connectionId(
89
+ input: Pick<ConnectionSettings, 'protocol' | 'host' | 'port'> & { remoteApp?: RemoteAppLaunch | null }
90
+ ): string {
91
+ const program = input.remoteApp?.program?.trim() ?? '';
92
+ const base = `${input.protocol || 'vnc'}:${input.host.trim()}:${input.port || ''}`;
93
+ // This format is load-bearing, not arbitrary: device libraries already on
94
+ // disk are keyed by it. Change it and every saved machine turns into a
95
+ // second, empty card the first time someone connects to it again.
96
+ return (program ? `${base}:${program}` : base).toLowerCase().slice(0, 64);
97
+ }
98
+
99
+ /** A blank connection, ready for the dialog to fill in. */
100
+ export function emptyConnection(protocol: RemoteProtocol = 'vnc'): ConnectionSettings {
101
+ const port = DEFAULT_PORTS[protocol];
102
+ return {
103
+ id: '',
104
+ label: '',
105
+ protocol,
106
+ host: '',
107
+ port,
108
+ username: '',
109
+ password: '',
110
+ domain: '',
111
+ security: 'auto',
112
+ remoteApp: null,
113
+ display: { ...DEFAULT_DISPLAY_SETTINGS },
114
+ ssh: { enabled: false, port: DEFAULT_PORTS.ssh, username: '' },
115
+ openSshOnConnect: false,
116
+ };
117
+ }
118
+
119
+ function clampLevel(value: unknown, fallback: number, min: number, max: number): number {
120
+ const level = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
121
+ if (!Number.isInteger(level)) return fallback;
122
+ return Math.min(max, Math.max(min, level));
123
+ }
124
+
125
+ export function normalizeDisplaySettings(value: unknown): DisplaySettings {
126
+ const input = (value ?? {}) as Partial<DisplaySettings>;
127
+ return {
128
+ quality: clampLevel(input.quality, DEFAULT_DISPLAY_SETTINGS.quality, 1, 9),
129
+ compression: clampLevel(input.compression, DEFAULT_DISPLAY_SETTINGS.compression, 0, 9),
130
+ scaling: typeof input.scaling === 'boolean' ? input.scaling : DEFAULT_DISPLAY_SETTINGS.scaling,
131
+ viewOnly: Boolean(input.viewOnly),
132
+ };
133
+ }
134
+
135
+ function normalizeRemoteApp(value: unknown): RemoteAppLaunch | null {
136
+ if (typeof value === 'string') {
137
+ const program = value.trim();
138
+ return program ? { program } : null;
139
+ }
140
+ if (!value || typeof value !== 'object') return null;
141
+
142
+ const input = value as Record<string, unknown>;
143
+ const program = String(input.program ?? '').trim();
144
+ if (!program) return null;
145
+
146
+ const workingDir = String(input.workingDir ?? '').trim();
147
+ const args = String(input.arguments ?? '').trim();
148
+ return {
149
+ program,
150
+ ...(workingDir ? { workingDir } : {}),
151
+ ...(args ? { arguments: args } : {}),
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Build a complete, valid connection out of whatever a caller happens to have.
157
+ * Partial input from a saved card, a host list entry, a URL or localStorage all
158
+ * arrive here, so the dialog never has to reason about missing fields.
159
+ */
160
+ export function normalizeConnection(input: Partial<ConnectionSettings> & Record<string, unknown>): ConnectionSettings {
161
+ const protocol = toRemoteProtocol(input.protocol);
162
+ const host = String(input.host ?? '').trim();
163
+ const port = toPort(input.port, DEFAULT_PORTS[protocol]);
164
+ const remoteApp = normalizeRemoteApp(input.remoteApp ?? input.program);
165
+
166
+ const sshInput = (input.ssh ?? {}) as Partial<SshCompanion>;
167
+ const ssh: SshCompanion = {
168
+ enabled: Boolean(sshInput.enabled),
169
+ port: toPort(sshInput.port, DEFAULT_PORTS.ssh),
170
+ username: String(sshInput.username ?? '').trim(),
171
+ };
172
+
173
+ const settings: ConnectionSettings = {
174
+ id: '',
175
+ label: String(input.label ?? '').trim() || host,
176
+ protocol,
177
+ host,
178
+ port,
179
+ username: String(input.username ?? '').trim(),
180
+ password: String(input.password ?? ''),
181
+ domain: String(input.domain ?? '').trim(),
182
+ security: toRdpSecurity(input.security),
183
+ remoteApp,
184
+ display: normalizeDisplaySettings(input.display),
185
+ ssh,
186
+ openSshOnConnect: Boolean(input.openSshOnConnect),
187
+ };
188
+
189
+ settings.id = String(input.id ?? '').trim() || connectionId(settings);
190
+ return settings;
191
+ }
192
+
193
+ /** What a connection is worth remembering by -- never the password. */
194
+ export type PersistedConnection = Omit<ConnectionSettings, 'password'>;
195
+
196
+ export function withoutPassword(settings: ConnectionSettings): PersistedConnection {
197
+ const { password: _password, ...rest } = settings;
198
+ return rest;
199
+ }
200
+
201
+ /** A one-line description for the dialog's summary row and the status bar. */
202
+ export function describeConnection(settings: ConnectionSettings): string {
203
+ const target = `${settings.host || 'no address'}:${settings.port}`;
204
+ const app = settings.remoteApp ? ` · ${settings.remoteApp.program}` : '';
205
+ return `${settings.protocol.toUpperCase()} · ${target}${app}`;
206
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A machine as the gateway remembers it, shared by the Durable Object that
3
+ * stores the record and the welcome screen that draws it.
4
+ *
5
+ * Passwords and account names are deliberately absent: the gateway has no need
6
+ * of them and every reason not to hold them. They live in the browser, next to
7
+ * the rest of the connection's settings.
8
+ */
9
+
10
+ import type { RdpSecurity, RemoteProtocol } from './protocol';
11
+
12
+ export interface SavedDevice {
13
+ id: string;
14
+ label: string;
15
+ host: string;
16
+ port: number;
17
+ protocol: RemoteProtocol;
18
+ domain?: string;
19
+ security?: RdpSecurity;
20
+ /** RemoteApp alias (`||notepad`) when this card launches an app, not a desktop. */
21
+ program?: string;
22
+ favorite?: boolean;
23
+ /** Milliseconds since the epoch; drives the "recent" ordering. */
24
+ lastUsed?: number;
25
+ createdAt?: number;
26
+ /** Set when a thumbnail exists, so the client can cache-bust its <img>. */
27
+ thumbnailAt?: number;
28
+ }