ai-remote 0.4.0 → 0.4.2

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 (166) hide show
  1. package/{src/index.ts → dist/index.d.ts} +0 -1
  2. package/dist/index.js +0 -1
  3. package/dist/protocols/index.d.ts +31 -0
  4. package/dist/protocols/index.js +0 -1
  5. package/dist/protocols/rdp/buffer.d.ts +38 -0
  6. package/dist/protocols/rdp/buffer.js +0 -1
  7. package/dist/protocols/rdp/caps.d.ts +49 -0
  8. package/dist/protocols/rdp/caps.js +0 -1
  9. package/dist/protocols/rdp/cert.d.ts +18 -0
  10. package/dist/protocols/rdp/cert.js +0 -1
  11. package/dist/protocols/rdp/client.d.ts +56 -0
  12. package/dist/protocols/rdp/client.js +0 -1
  13. package/dist/protocols/rdp/cliprdr.d.ts +27 -0
  14. package/dist/protocols/rdp/cliprdr.js +0 -1
  15. package/dist/protocols/rdp/credssp.d.ts +34 -0
  16. package/dist/protocols/rdp/credssp.js +0 -1
  17. package/dist/protocols/rdp/crypto.d.ts +63 -0
  18. package/dist/protocols/rdp/crypto.js +0 -1
  19. package/dist/protocols/rdp/display.d.ts +38 -0
  20. package/dist/protocols/rdp/display.js +0 -1
  21. package/dist/protocols/rdp/gcc.d.ts +23 -0
  22. package/dist/protocols/rdp/gcc.js +0 -1
  23. package/dist/protocols/rdp/keymap.d.ts +9 -0
  24. package/dist/protocols/rdp/keymap.js +0 -1
  25. package/dist/protocols/rdp/mcs.d.ts +40 -0
  26. package/dist/protocols/rdp/mcs.js +0 -1
  27. package/dist/protocols/rdp/ntlm.d.ts +61 -0
  28. package/dist/protocols/rdp/ntlm.js +0 -1
  29. package/dist/protocols/rdp/pdu.d.ts +155 -0
  30. package/dist/protocols/rdp/pdu.js +0 -1
  31. package/dist/protocols/rdp/rail.d.ts +119 -0
  32. package/dist/protocols/rdp/rail.js +0 -1
  33. package/dist/protocols/rdp/rle.d.ts +13 -0
  34. package/dist/protocols/rdp/rle.js +0 -1
  35. package/dist/protocols/rdp/sec.d.ts +59 -0
  36. package/dist/protocols/rdp/sec.js +0 -1
  37. package/dist/protocols/rdp/session.d.ts +62 -0
  38. package/dist/protocols/rdp/session.js +0 -1
  39. package/dist/protocols/rdp/tls.d.ts +18 -0
  40. package/dist/protocols/rdp/tls.js +0 -1
  41. package/dist/protocols/rdp/vchannel.d.ts +28 -0
  42. package/dist/protocols/rdp/vchannel.js +0 -1
  43. package/dist/protocols/rdp/x224.d.ts +40 -0
  44. package/dist/protocols/rdp/x224.js +0 -1
  45. package/dist/protocols/ssh/kex.d.ts +97 -0
  46. package/dist/protocols/ssh/kex.js +0 -1
  47. package/dist/protocols/ssh/messages.d.ts +50 -0
  48. package/dist/protocols/ssh/messages.js +0 -1
  49. package/dist/protocols/ssh/session.d.ts +66 -0
  50. package/dist/protocols/ssh/session.js +0 -1
  51. package/dist/protocols/ssh/terminal.d.ts +46 -0
  52. package/dist/protocols/ssh/terminal.js +0 -1
  53. package/dist/protocols/ssh/transport.d.ts +62 -0
  54. package/dist/protocols/ssh/transport.js +0 -1
  55. package/dist/protocols/ssh/wire.d.ts +52 -0
  56. package/dist/protocols/ssh/wire.js +0 -1
  57. package/dist/protocols/types.d.ts +127 -0
  58. package/dist/protocols/types.js +0 -1
  59. package/dist/protocols/vnc/input.d.ts +5 -0
  60. package/dist/protocols/vnc/input.js +0 -1
  61. package/dist/protocols/vnc/session.d.ts +12 -0
  62. package/dist/protocols/vnc/session.js +0 -1
  63. package/dist/shared/connection.d.ts +81 -0
  64. package/dist/shared/connection.js +0 -1
  65. package/dist/shared/device.d.ts +26 -0
  66. package/dist/shared/device.js +0 -1
  67. package/dist/shared/hosts.d.ts +63 -0
  68. package/dist/shared/hosts.js +0 -1
  69. package/dist/shared/icons.d.ts +46 -0
  70. package/dist/shared/icons.js +0 -1
  71. package/dist/shared/protocol.d.ts +28 -0
  72. package/dist/shared/protocol.js +0 -1
  73. package/dist/shared/signals.d.ts +37 -0
  74. package/dist/shared/signals.js +0 -1
  75. package/package.json +10 -11
  76. package/dist/index.js.map +0 -7
  77. package/dist/protocols/index.js.map +0 -7
  78. package/dist/protocols/rdp/buffer.js.map +0 -7
  79. package/dist/protocols/rdp/caps.js.map +0 -7
  80. package/dist/protocols/rdp/cert.js.map +0 -7
  81. package/dist/protocols/rdp/client.js.map +0 -7
  82. package/dist/protocols/rdp/cliprdr.js.map +0 -7
  83. package/dist/protocols/rdp/credssp.js.map +0 -7
  84. package/dist/protocols/rdp/crypto.js.map +0 -7
  85. package/dist/protocols/rdp/display.js.map +0 -7
  86. package/dist/protocols/rdp/gcc.js.map +0 -7
  87. package/dist/protocols/rdp/keymap.js.map +0 -7
  88. package/dist/protocols/rdp/mcs.js.map +0 -7
  89. package/dist/protocols/rdp/ntlm.js.map +0 -7
  90. package/dist/protocols/rdp/pdu.js.map +0 -7
  91. package/dist/protocols/rdp/rail.js.map +0 -7
  92. package/dist/protocols/rdp/rle.js.map +0 -7
  93. package/dist/protocols/rdp/sec.js.map +0 -7
  94. package/dist/protocols/rdp/session.js.map +0 -7
  95. package/dist/protocols/rdp/tls.js.map +0 -7
  96. package/dist/protocols/rdp/vchannel.js.map +0 -7
  97. package/dist/protocols/rdp/x224.js.map +0 -7
  98. package/dist/protocols/ssh/kex.js.map +0 -7
  99. package/dist/protocols/ssh/messages.js.map +0 -7
  100. package/dist/protocols/ssh/session.js.map +0 -7
  101. package/dist/protocols/ssh/terminal.js.map +0 -7
  102. package/dist/protocols/ssh/transport.js.map +0 -7
  103. package/dist/protocols/ssh/wire.js.map +0 -7
  104. package/dist/protocols/types.js.map +0 -7
  105. package/dist/protocols/vnc/input.js.map +0 -7
  106. package/dist/protocols/vnc/session.js.map +0 -7
  107. package/dist/shared/connection.js.map +0 -7
  108. package/dist/shared/device.js.map +0 -7
  109. package/dist/shared/hosts.js.map +0 -7
  110. package/dist/shared/icons.js.map +0 -7
  111. package/dist/shared/protocol.js.map +0 -7
  112. package/dist/shared/signals.js.map +0 -7
  113. package/src/cli/cli.ts +0 -462
  114. package/src/cli/daemon.ts +0 -306
  115. package/src/cli/framebuffer.ts +0 -115
  116. package/src/cli/generated/viewer-bundle.ts +0 -5
  117. package/src/cli/ipc.ts +0 -78
  118. package/src/cli/native/window.swift +0 -151
  119. package/src/cli/paths.ts +0 -28
  120. package/src/cli/png.ts +0 -106
  121. package/src/cli/session.ts +0 -263
  122. package/src/cli/shell.ts +0 -184
  123. package/src/cli/transport.ts +0 -86
  124. package/src/cli/viewer-client/assets.d.ts +0 -4
  125. package/src/cli/viewer-client/main.ts +0 -323
  126. package/src/cli/viewer-page.ts +0 -138
  127. package/src/cli/viewer.ts +0 -296
  128. package/src/cli/window.ts +0 -140
  129. package/src/cli/wsserver.ts +0 -144
  130. package/src/protocols/index.ts +0 -72
  131. package/src/protocols/rdp/buffer.ts +0 -204
  132. package/src/protocols/rdp/caps.ts +0 -304
  133. package/src/protocols/rdp/cert.ts +0 -190
  134. package/src/protocols/rdp/client.ts +0 -1371
  135. package/src/protocols/rdp/cliprdr.ts +0 -344
  136. package/src/protocols/rdp/credssp.ts +0 -319
  137. package/src/protocols/rdp/crypto.ts +0 -416
  138. package/src/protocols/rdp/display.ts +0 -750
  139. package/src/protocols/rdp/gcc.ts +0 -162
  140. package/src/protocols/rdp/keymap.ts +0 -68
  141. package/src/protocols/rdp/mcs.ts +0 -283
  142. package/src/protocols/rdp/ntlm.ts +0 -380
  143. package/src/protocols/rdp/pdu.ts +0 -497
  144. package/src/protocols/rdp/rail.ts +0 -480
  145. package/src/protocols/rdp/rle.ts +0 -343
  146. package/src/protocols/rdp/sec.ts +0 -184
  147. package/src/protocols/rdp/session.ts +0 -393
  148. package/src/protocols/rdp/tls.ts +0 -452
  149. package/src/protocols/rdp/vchannel.ts +0 -89
  150. package/src/protocols/rdp/x224.ts +0 -139
  151. package/src/protocols/ssh/kex.ts +0 -227
  152. package/src/protocols/ssh/messages.ts +0 -54
  153. package/src/protocols/ssh/session.ts +0 -879
  154. package/src/protocols/ssh/terminal.ts +0 -242
  155. package/src/protocols/ssh/transport.ts +0 -745
  156. package/src/protocols/ssh/wire.ts +0 -220
  157. package/src/protocols/types.ts +0 -146
  158. package/src/protocols/vnc/input.ts +0 -13
  159. package/src/protocols/vnc/novnc.d.ts +0 -75
  160. package/src/protocols/vnc/session.ts +0 -367
  161. package/src/shared/connection.ts +0 -206
  162. package/src/shared/device.ts +0 -28
  163. package/src/shared/hosts.ts +0 -240
  164. package/src/shared/icons.ts +0 -49
  165. package/src/shared/protocol.ts +0 -55
  166. package/src/shared/signals.ts +0 -67
package/src/cli/ipc.ts DELETED
@@ -1,78 +0,0 @@
1
- /**
2
- * The conversation between a command and the session it attaches to.
3
- *
4
- * Newline-delimited JSON over a unix socket. Nothing here crosses a network,
5
- * so the format is chosen to be obvious in a log rather than compact.
6
- */
7
-
8
- import net from 'node:net';
9
- import { StringDecoder } from 'node:string_decoder';
10
-
11
- export interface Request {
12
- id: number;
13
- op: string;
14
- args?: Record<string, unknown>;
15
- }
16
-
17
- export interface Response {
18
- id: number;
19
- ok: boolean;
20
- result?: unknown;
21
- error?: string;
22
- /** The exit code this failure should produce, when it has a natural one. */
23
- code?: number;
24
- }
25
-
26
- /**
27
- * Read newline-delimited JSON off a socket, one object at a time.
28
- *
29
- * The decoder is stateful rather than a `toString()` per chunk, because a read
30
- * boundary can fall inside a multi-byte character: `type HOST "café"` split at
31
- * the wrong byte used to arrive as a replacement character. It holds the
32
- * incomplete tail until the rest of it turns up.
33
- */
34
- export function readMessages(socket: net.Socket, onMessage: (value: any) => void): void {
35
- const decoder = new StringDecoder('utf8');
36
- let buffer = '';
37
- socket.on('data', (chunk) => {
38
- buffer += decoder.write(chunk as Buffer);
39
- for (;;) {
40
- const newline = buffer.indexOf('\n');
41
- if (newline === -1) return;
42
- const line = buffer.slice(0, newline);
43
- buffer = buffer.slice(newline + 1);
44
- if (!line.trim()) continue;
45
- try { onMessage(JSON.parse(line)); } catch { /* a truncated write; drop it */ }
46
- }
47
- });
48
- }
49
-
50
- export const writeMessage = (socket: net.Socket, value: unknown): void => {
51
- socket.write(`${JSON.stringify(value)}\n`);
52
- };
53
-
54
- /** Send one request to a running session and wait for its answer. */
55
- export function request(path: string, op: string, args: Record<string, unknown> = {}, timeoutMs = 120_000): Promise<Response> {
56
- return new Promise((resolve, reject) => {
57
- const socket = net.connect(path);
58
- let settled = false;
59
-
60
- const finish = (error: Error | null, response?: Response) => {
61
- if (settled) return;
62
- settled = true;
63
- clearTimeout(timer);
64
- socket.destroy();
65
- if (error) reject(error); else resolve(response!);
66
- };
67
-
68
- const timer = setTimeout(() => finish(new Error(`The session did not answer "${op}" in time.`)), timeoutMs);
69
-
70
- socket.on('connect', () => writeMessage(socket, { id: 1, op, args }));
71
- socket.on('error', (error: NodeJS.ErrnoException) => {
72
- // ENOENT and ECONNREFUSED both mean the same thing to a caller: the
73
- // session named by that socket is not running.
74
- finish(Object.assign(error, { notRunning: error.code === 'ENOENT' || error.code === 'ECONNREFUSED' }));
75
- });
76
- readMessages(socket, (message) => finish(null, message as Response));
77
- });
78
- }
@@ -1,151 +0,0 @@
1
- // A standalone macOS window for the session viewer.
2
- //
3
- // No browser is involved: this is an NSWindow holding a WKWebView, which is the
4
- // WebKit that ships with the system. So the session gets a real application
5
- // window -- its own dock entry, the platform's own traffic lights, and the green
6
- // button doing native fullscreen -- without Chrome, without Electron, and
7
- // without a compiled dependency in the package.
8
- //
9
- // Built on first use by src/cli/native.ts and cached; the source is shipped
10
- // rather than a binary, so nothing here has to be trusted or code-signed.
11
-
12
- import AppKit
13
- import WebKit
14
-
15
- struct Options {
16
- var url = ""
17
- var title = "ai-remote"
18
- var width: CGFloat = 1280
19
- var height: CGFloat = 892
20
- var fullscreen = false
21
- }
22
-
23
- func parse() -> Options {
24
- var options = Options()
25
- var arguments = Array(CommandLine.arguments.dropFirst())
26
-
27
- while let argument = arguments.first {
28
- arguments.removeFirst()
29
- switch argument {
30
- case "--title": options.title = arguments.isEmpty ? options.title : arguments.removeFirst()
31
- case "--width": options.width = CGFloat(Double(arguments.removeFirst()) ?? 1280)
32
- case "--height": options.height = CGFloat(Double(arguments.removeFirst()) ?? 892)
33
- case "--fullscreen": options.fullscreen = true
34
- default: if options.url.isEmpty { options.url = argument }
35
- }
36
- }
37
- return options
38
- }
39
-
40
- final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
41
- let options: Options
42
- var window: NSWindow!
43
- var web: WKWebView!
44
-
45
- init(options: Options) {
46
- self.options = options
47
- super.init()
48
- }
49
-
50
- func applicationDidFinishLaunching(_ notification: Notification) {
51
- let configuration = WKWebViewConfiguration()
52
- // The viewer is served from loopback and talks to its own session; nothing
53
- // it holds should outlive the window.
54
- configuration.websiteDataStore = .nonPersistent()
55
-
56
- web = WKWebView(frame: .zero, configuration: configuration)
57
- web.navigationDelegate = self
58
- web.setValue(false, forKey: "drawsBackground")
59
-
60
- window = NSWindow(
61
- contentRect: NSRect(x: 0, y: 0, width: options.width, height: options.height),
62
- // .resizable is what puts the green button in the corner and lets it
63
- // mean fullscreen rather than zoom.
64
- styleMask: [.titled, .closable, .miniaturizable, .resizable],
65
- backing: .buffered,
66
- defer: false
67
- )
68
- window.title = options.title
69
- window.contentView = web
70
- window.setFrameAutosaveName("ai-remote-viewer")
71
- window.collectionBehavior.insert(.fullScreenPrimary)
72
- window.center()
73
- window.makeKeyAndOrderFront(nil)
74
-
75
- buildMenu()
76
-
77
- if let url = URL(string: options.url) {
78
- web.load(URLRequest(url: url))
79
- } else {
80
- FileHandle.standardError.write("ai-remote-window: not a URL: \(options.url)\n".data(using: .utf8)!)
81
- exit(2)
82
- }
83
-
84
- if options.fullscreen {
85
- // After the first frame, or the window has no screen to fill yet.
86
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { self.window.toggleFullScreen(nil) }
87
- }
88
-
89
- NSApp.activate(ignoringOtherApps: true)
90
- }
91
-
92
- /// Enough of a menu for the shortcuts a window is expected to answer.
93
- private func buildMenu() {
94
- let main = NSMenu()
95
-
96
- let appItem = NSMenuItem()
97
- let appMenu = NSMenu()
98
- appMenu.addItem(withTitle: "Hide \(options.title)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
99
- appMenu.addItem(NSMenuItem.separator())
100
- appMenu.addItem(withTitle: "Quit \(options.title)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
101
- appItem.submenu = appMenu
102
- main.addItem(appItem)
103
-
104
- let viewItem = NSMenuItem()
105
- let viewMenu = NSMenu(title: "View")
106
- let full = NSMenuItem(title: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f")
107
- full.keyEquivalentModifierMask = [.command, .control]
108
- viewMenu.addItem(full)
109
- let reload = NSMenuItem(title: "Reload", action: #selector(WKWebView.reload(_:)), keyEquivalent: "r")
110
- viewMenu.addItem(reload)
111
- viewItem.submenu = viewMenu
112
- main.addItem(viewItem)
113
-
114
- let editItem = NSMenuItem()
115
- let editMenu = NSMenu(title: "Edit")
116
- for (title, action, key) in [
117
- ("Cut", #selector(NSText.cut(_:)), "x"),
118
- ("Copy", #selector(NSText.copy(_:)), "c"),
119
- ("Paste", #selector(NSText.paste(_:)), "v"),
120
- ("Select All", #selector(NSText.selectAll(_:)), "a"),
121
- ] {
122
- editMenu.addItem(withTitle: title, action: action, keyEquivalent: key)
123
- }
124
- editItem.submenu = editMenu
125
- main.addItem(editItem)
126
-
127
- NSApp.mainMenu = main
128
- }
129
-
130
- func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
131
-
132
- func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
133
- FileHandle.standardError.write("ai-remote-window: \(error.localizedDescription)\n".data(using: .utf8)!)
134
- }
135
-
136
- func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
137
- FileHandle.standardError.write("ai-remote-window: \(error.localizedDescription)\n".data(using: .utf8)!)
138
- }
139
- }
140
-
141
- let options = parse()
142
- if options.url.isEmpty {
143
- FileHandle.standardError.write("usage: ai-remote-window <url> [--title T] [--width N] [--height N] [--fullscreen]\n".data(using: .utf8)!)
144
- exit(2)
145
- }
146
-
147
- let application = NSApplication.shared
148
- application.setActivationPolicy(.regular)
149
- let delegate = Delegate(options: options)
150
- application.delegate = delegate
151
- application.run()
package/src/cli/paths.ts DELETED
@@ -1,28 +0,0 @@
1
- /**
2
- * Where a running session leaves its door open.
3
- *
4
- * One directory per user, mode 0700, because the socket in it accepts input
5
- * for a live desktop: anything that can reach it can type on the machine.
6
- */
7
-
8
- import { mkdirSync } from 'node:fs';
9
- import { homedir } from 'node:os';
10
- import { join } from 'node:path';
11
-
12
- export const ROOT = join(homedir(), '.ai-remote');
13
- export const RUN_DIR = join(ROOT, 'run');
14
-
15
- export function ensureRunDir(): string {
16
- mkdirSync(RUN_DIR, { recursive: true, mode: 0o700 });
17
- return RUN_DIR;
18
- }
19
-
20
- /** A stable name for a target, so a second command finds the first one's session. */
21
- export function sessionName(host: string, port: number, explicit?: string): string {
22
- if (explicit) return explicit.replace(/[^\w.-]/g, '_');
23
- return `${host}_${port}`.replace(/[^\w.-]/g, '_');
24
- }
25
-
26
- export const socketPath = (name: string) => join(RUN_DIR, `${name}.sock`);
27
- export const metaPath = (name: string) => join(RUN_DIR, `${name}.json`);
28
- export const logPath = (name: string) => join(RUN_DIR, `${name}.log`);
package/src/cli/png.ts DELETED
@@ -1,106 +0,0 @@
1
- /**
2
- * A PNG writer, so a screenshot needs no dependency.
3
- *
4
- * The browser encodes JPEG through the canvas it already has. Node has no
5
- * image encoder at all, but it does have DEFLATE -- which is the only
6
- * compression PNG defines -- so the rest is three chunks and two checksums.
7
- */
8
-
9
- import { deflateSync } from 'node:zlib';
10
-
11
- const CRC_TABLE = (() => {
12
- const table = new Uint32Array(256);
13
- for (let n = 0; n < 256; n++) {
14
- let c = n;
15
- for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
16
- table[n] = c >>> 0;
17
- }
18
- return table;
19
- })();
20
-
21
- function crc32(bytes: Uint8Array): number {
22
- let c = 0xffffffff;
23
- for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
24
- return (c ^ 0xffffffff) >>> 0;
25
- }
26
-
27
- function chunk(type: string, body: Uint8Array): Uint8Array {
28
- const out = new Uint8Array(12 + body.length);
29
- const view = new DataView(out.buffer);
30
- view.setUint32(0, body.length);
31
- for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
32
- out.set(body, 8);
33
- view.setUint32(8 + body.length, crc32(out.subarray(4, 8 + body.length)));
34
- return out;
35
- }
36
-
37
- /** RGBA in, PNG out. Rows are filtered `None`, which DEFLATE handles well enough. */
38
- export function encodePng(rgba: Uint8Array, width: number, height: number): Uint8Array {
39
- const stride = width * 4;
40
- const raw = new Uint8Array((stride + 1) * height);
41
- for (let y = 0; y < height; y++) {
42
- raw[y * (stride + 1)] = 0;
43
- raw.set(rgba.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1);
44
- }
45
-
46
- const ihdr = new Uint8Array(13);
47
- const view = new DataView(ihdr.buffer);
48
- view.setUint32(0, width);
49
- view.setUint32(4, height);
50
- ihdr[8] = 8; // bit depth
51
- ihdr[9] = 6; // colour type: truecolour with alpha
52
- ihdr[10] = 0; // compression: deflate
53
- ihdr[11] = 0; // filter: adaptive
54
- ihdr[12] = 0; // interlace: none
55
-
56
- const parts = [
57
- new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
58
- chunk('IHDR', ihdr),
59
- chunk('IDAT', new Uint8Array(deflateSync(raw, { level: 6 }))),
60
- chunk('IEND', new Uint8Array(0)),
61
- ];
62
-
63
- const total = parts.reduce((sum, part) => sum + part.length, 0);
64
- const png = new Uint8Array(total);
65
- let offset = 0;
66
- for (const part of parts) {
67
- png.set(part, offset);
68
- offset += part.length;
69
- }
70
- return png;
71
- }
72
-
73
- /**
74
- * Nearest-neighbour downscale to fit `maxEdge`.
75
- *
76
- * A box filter would look better, but this frame exists to be read -- by a
77
- * model or by a person checking what happened -- and nearest-neighbour keeps
78
- * single-pixel text strokes from being averaged into grey.
79
- */
80
- export function downscale(
81
- rgba: Uint8Array,
82
- width: number,
83
- height: number,
84
- maxEdge: number
85
- ): { rgba: Uint8Array; width: number; height: number } {
86
- const scale = Math.min(1, maxEdge / Math.max(width, height));
87
- if (scale >= 1) return { rgba, width, height };
88
-
89
- const outWidth = Math.max(1, Math.round(width * scale));
90
- const outHeight = Math.max(1, Math.round(height * scale));
91
- const out = new Uint8Array(outWidth * outHeight * 4);
92
-
93
- for (let y = 0; y < outHeight; y++) {
94
- const sourceY = Math.min(height - 1, Math.floor(y / scale));
95
- for (let x = 0; x < outWidth; x++) {
96
- const sourceX = Math.min(width - 1, Math.floor(x / scale));
97
- const from = (sourceY * width + sourceX) * 4;
98
- const to = (y * outWidth + x) * 4;
99
- out[to] = rgba[from];
100
- out[to + 1] = rgba[from + 1];
101
- out[to + 2] = rgba[from + 2];
102
- out[to + 3] = rgba[from + 3];
103
- }
104
- }
105
- return { rgba: out, width: outWidth, height: outHeight };
106
- }
@@ -1,263 +0,0 @@
1
- /**
2
- * One RDP session, headless.
3
- *
4
- * The same client the browser runs, given a TCP transport and a framebuffer
5
- * instead of a WebSocket and a canvas. Nothing above the transport knows the
6
- * difference, which is the point of the seam.
7
- */
8
-
9
- import { RdpClient } from '../protocols/rdp/client';
10
- import { Framebuffer, type PaintedRect } from './framebuffer';
11
- import { TcpTransport } from './transport';
12
-
13
- export interface SessionOptions {
14
- host: string;
15
- port: number;
16
- username: string;
17
- password: string;
18
- domain: string;
19
- width: number;
20
- height: number;
21
- security: 'auto' | 'nla' | 'tls' | 'rdp';
22
- /** Shown in this session's own log lines and sent to the host. */
23
- clientName: string;
24
- /**
25
- * Where the bytes go. Defaults to a TCP socket at `host:port`.
26
- *
27
- * Injectable for the same reason the client's is: a test can drive a whole
28
- * session against a mock host with no network, and a future gateway mode can
29
- * point the same session at a relay instead.
30
- */
31
- openTransport?: () => any;
32
- }
33
-
34
- export interface PointerState {
35
- x: number;
36
- y: number;
37
- }
38
-
39
- type Listener = (rects: PaintedRect[], framebuffer: Framebuffer) => void;
40
-
41
- export class RdpSession {
42
- readonly framebuffer = new Framebuffer();
43
- readonly options: SessionOptions;
44
-
45
- client: any;
46
- connected = false;
47
- lastError = '';
48
-
49
- #painters = new Set<Listener>();
50
- #resizers = new Set<(width: number, height: number) => void>();
51
- #pointer: PointerState = { x: 0, y: 0 };
52
-
53
- constructor(options: SessionOptions) {
54
- this.options = options;
55
-
56
- this.client = new RdpClient(`tcp://${options.host}:${options.port}`, {
57
- width: options.width,
58
- height: options.height,
59
- username: options.username,
60
- password: options.password,
61
- domain: options.domain,
62
- security: options.security,
63
- clientName: options.clientName,
64
- clipboard: true,
65
- openTransport: options.openTransport ?? (() => new TcpTransport(options.host, options.port)),
66
- });
67
-
68
- this.client.addEventListener('ready', (event: any) => {
69
- const { width, height } = event.detail;
70
- this.framebuffer.resize(width, height);
71
- this.connected = true;
72
- for (const notify of this.#resizers) notify(width, height);
73
- });
74
-
75
- this.client.addEventListener('resize', (event: any) => {
76
- const { width, height } = event.detail;
77
- this.framebuffer.resize(width, height);
78
- for (const notify of this.#resizers) notify(width, height);
79
- });
80
-
81
- this.client.addEventListener('bitmap', (event: any) => {
82
- const painted = this.framebuffer.draw(event.detail.rects, event.detail.palette ?? null);
83
- if (painted.length) for (const notify of this.#painters) notify(painted, this.framebuffer);
84
- });
85
-
86
- this.client.addEventListener('palette', (event: any) => {
87
- this.framebuffer.palette = event.detail;
88
- });
89
-
90
- this.client.addEventListener('error', (event: any) => {
91
- this.lastError = event.detail?.message || '';
92
- });
93
-
94
- this.client.addEventListener('close', () => {
95
- this.connected = false;
96
- });
97
- }
98
-
99
- onPaint(listener: Listener): () => void {
100
- this.#painters.add(listener);
101
- return () => this.#painters.delete(listener);
102
- }
103
-
104
- onResize(listener: (width: number, height: number) => void): () => void {
105
- this.#resizers.add(listener);
106
- return () => this.#resizers.delete(listener);
107
- }
108
-
109
- /** Resolves when the desktop is up, or rejects with what the host said. */
110
- connect(timeoutMs = 30_000): Promise<{ width: number; height: number }> {
111
- return new Promise((resolve, reject) => {
112
- let settled = false;
113
- const finish = (error: Error | null, size?: { width: number; height: number }) => {
114
- if (settled) return;
115
- settled = true;
116
- clearTimeout(timer);
117
- if (error) reject(error); else resolve(size!);
118
- };
119
-
120
- const timer = setTimeout(
121
- () => {
122
- this.client.disconnect();
123
- finish(new Error(`The host did not present a desktop within ${Math.round(timeoutMs / 1000)}s.`));
124
- },
125
- timeoutMs
126
- );
127
-
128
- this.client.addEventListener('ready', (event: any) => finish(null, event.detail));
129
- this.client.addEventListener('close', (event: any) => {
130
- const detail = event.detail || {};
131
- finish(new Error(this.lastError || detail.message || 'The connection closed during the handshake.'));
132
- });
133
-
134
- // RdpClient opens its transport in its own constructor, so by the time
135
- // this runs the negotiation is already in flight. Listeners attached
136
- // here still arrive first: every transport event is asynchronous.
137
- });
138
- }
139
-
140
- disconnect(): void {
141
- // The client suppresses its own close event on an explicit disconnect, so
142
- // this is the only place that can answer "is it still connected" honestly.
143
- this.connected = false;
144
- this.client.disconnect();
145
- }
146
-
147
- // --- input ----------------------------------------------------------------
148
- //
149
- // These mirror what the browser's display layer does with a DOM event: the
150
- // pointer is positioned and the button flag travels with it, and a key is
151
- // sent by its DOM code because that is what maps to a scancode. The keysym
152
- // is only the fallback for a character with no key of its own -- the same
153
- // order of preference the RDP driver uses.
154
-
155
- get pointer(): PointerState {
156
- return { ...this.#pointer };
157
- }
158
-
159
- movePointer(x: number, y: number): void {
160
- this.#pointer = this.#clamp(x, y);
161
- this.client.sendMouseMove(this.#pointer.x, this.#pointer.y);
162
- }
163
-
164
- /** `button` is 0 left, 1 middle, 2 right, as the UI numbers them. */
165
- pointerButton(x: number, y: number, button: number, pressed: boolean): void {
166
- this.#pointer = this.#clamp(x, y);
167
- this.client.sendMouseButton(button, pressed, this.#pointer.x, this.#pointer.y);
168
- }
169
-
170
- click(x: number, y: number, button = 0): void {
171
- this.movePointer(x, y);
172
- this.pointerButton(x, y, button, true);
173
- this.pointerButton(x, y, button, false);
174
- }
175
-
176
- /**
177
- * Scroll by whole notches, positive being down and right -- the direction
178
- * the driver interface names.
179
- *
180
- * The client's `sendWheel` takes a direction and emits exactly one notch, so
181
- * a count has to become that many calls. Passing 3 to it would scroll one
182
- * notch, not three, which is a quiet way to look like scrolling is broken.
183
- */
184
- scroll(x: number, y: number, notchesX: number, notchesY: number): void {
185
- this.#pointer = this.#clamp(x, y);
186
- const { x: px, y: py } = this.#pointer;
187
-
188
- for (let i = 0; i < Math.abs(notchesY); i++) {
189
- this.client.sendWheel(Math.sign(notchesY), px, py, false);
190
- }
191
- for (let i = 0; i < Math.abs(notchesX); i++) {
192
- this.client.sendWheel(Math.sign(notchesX), px, py, true);
193
- }
194
- }
195
-
196
- typeText(text: string): void {
197
- this.client.sendUnicode(text);
198
- }
199
-
200
- /** One key by its DOM code, e.g. `Enter`, `ControlLeft`, `F5`. */
201
- sendKeyCode(code: string, pressed: boolean): boolean {
202
- return this.client.sendKeyByCode(code, pressed) !== false;
203
- }
204
-
205
- sendCtrlAltDel(): void {
206
- this.client.sendCtrlAltDel();
207
- }
208
-
209
- /** Ask the host to repaint the whole desktop, so a screenshot is complete. */
210
- requestFullRepaint(): void {
211
- if (!this.framebuffer.width) return;
212
- this.client.refresh({
213
- left: 0,
214
- top: 0,
215
- right: this.framebuffer.width - 1,
216
- bottom: this.framebuffer.height - 1,
217
- });
218
- }
219
-
220
- /**
221
- * Resolve once the desktop has painted and then gone quiet.
222
- *
223
- * A screenshot taken the instant the handshake finishes is black: the host
224
- * has agreed on a desktop size but has not sent any of it yet. Waiting for a
225
- * fixed delay instead would be either too short on a busy machine or wasted
226
- * time on an idle one, so this waits for the thing that actually matters --
227
- * pixels arriving, and then stopping.
228
- */
229
- settle({ quietMs = 500, timeoutMs = 8000 } = {}): Promise<{ painted: boolean; waitedMs: number }> {
230
- return new Promise((resolve) => {
231
- const started = Date.now();
232
- const deadline = started + timeoutMs;
233
- let seen = -1;
234
- let quietSince = Date.now();
235
-
236
- const timer = setInterval(() => {
237
- const generation = this.framebuffer.generation;
238
- if (generation !== seen) {
239
- seen = generation;
240
- quietSince = Date.now();
241
- }
242
-
243
- const painted = this.framebuffer.paints > 0;
244
- const quiet = Date.now() - quietSince >= quietMs;
245
- if ((painted && quiet) || Date.now() >= deadline) {
246
- clearInterval(timer);
247
- resolve({ painted, waitedMs: Date.now() - started });
248
- }
249
- }, 80);
250
- });
251
- }
252
-
253
- setClipboard(text: string): void {
254
- this.client.setClipboardText(text);
255
- }
256
-
257
- #clamp(x: number, y: number): PointerState {
258
- return {
259
- x: Math.max(0, Math.min(Math.max(0, this.framebuffer.width - 1), Math.round(x))),
260
- y: Math.max(0, Math.min(Math.max(0, this.framebuffer.height - 1), Math.round(y))),
261
- };
262
- }
263
- }