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.
- package/{src/index.ts → dist/index.d.ts} +0 -1
- package/dist/index.js +0 -1
- package/dist/protocols/index.d.ts +31 -0
- package/dist/protocols/index.js +0 -1
- package/dist/protocols/rdp/buffer.d.ts +38 -0
- package/dist/protocols/rdp/buffer.js +0 -1
- package/dist/protocols/rdp/caps.d.ts +49 -0
- package/dist/protocols/rdp/caps.js +0 -1
- package/dist/protocols/rdp/cert.d.ts +18 -0
- package/dist/protocols/rdp/cert.js +0 -1
- package/dist/protocols/rdp/client.d.ts +56 -0
- package/dist/protocols/rdp/client.js +0 -1
- package/dist/protocols/rdp/cliprdr.d.ts +27 -0
- package/dist/protocols/rdp/cliprdr.js +0 -1
- package/dist/protocols/rdp/credssp.d.ts +34 -0
- package/dist/protocols/rdp/credssp.js +0 -1
- package/dist/protocols/rdp/crypto.d.ts +63 -0
- package/dist/protocols/rdp/crypto.js +0 -1
- package/dist/protocols/rdp/display.d.ts +38 -0
- package/dist/protocols/rdp/display.js +0 -1
- package/dist/protocols/rdp/gcc.d.ts +23 -0
- package/dist/protocols/rdp/gcc.js +0 -1
- package/dist/protocols/rdp/keymap.d.ts +9 -0
- package/dist/protocols/rdp/keymap.js +0 -1
- package/dist/protocols/rdp/mcs.d.ts +40 -0
- package/dist/protocols/rdp/mcs.js +0 -1
- package/dist/protocols/rdp/ntlm.d.ts +61 -0
- package/dist/protocols/rdp/ntlm.js +0 -1
- package/dist/protocols/rdp/pdu.d.ts +155 -0
- package/dist/protocols/rdp/pdu.js +0 -1
- package/dist/protocols/rdp/rail.d.ts +119 -0
- package/dist/protocols/rdp/rail.js +0 -1
- package/dist/protocols/rdp/rle.d.ts +13 -0
- package/dist/protocols/rdp/rle.js +0 -1
- package/dist/protocols/rdp/sec.d.ts +59 -0
- package/dist/protocols/rdp/sec.js +0 -1
- package/dist/protocols/rdp/session.d.ts +62 -0
- package/dist/protocols/rdp/session.js +0 -1
- package/dist/protocols/rdp/tls.d.ts +18 -0
- package/dist/protocols/rdp/tls.js +0 -1
- package/dist/protocols/rdp/vchannel.d.ts +28 -0
- package/dist/protocols/rdp/vchannel.js +0 -1
- package/dist/protocols/rdp/x224.d.ts +40 -0
- package/dist/protocols/rdp/x224.js +0 -1
- package/dist/protocols/ssh/kex.d.ts +97 -0
- package/dist/protocols/ssh/kex.js +0 -1
- package/dist/protocols/ssh/messages.d.ts +50 -0
- package/dist/protocols/ssh/messages.js +0 -1
- package/dist/protocols/ssh/session.d.ts +66 -0
- package/dist/protocols/ssh/session.js +0 -1
- package/dist/protocols/ssh/terminal.d.ts +46 -0
- package/dist/protocols/ssh/terminal.js +0 -1
- package/dist/protocols/ssh/transport.d.ts +62 -0
- package/dist/protocols/ssh/transport.js +0 -1
- package/dist/protocols/ssh/wire.d.ts +52 -0
- package/dist/protocols/ssh/wire.js +0 -1
- package/dist/protocols/types.d.ts +127 -0
- package/dist/protocols/types.js +0 -1
- package/dist/protocols/vnc/input.d.ts +5 -0
- package/dist/protocols/vnc/input.js +0 -1
- package/dist/protocols/vnc/session.d.ts +12 -0
- package/dist/protocols/vnc/session.js +0 -1
- package/dist/shared/connection.d.ts +81 -0
- package/dist/shared/connection.js +0 -1
- package/dist/shared/device.d.ts +26 -0
- package/dist/shared/device.js +0 -1
- package/dist/shared/hosts.d.ts +63 -0
- package/dist/shared/hosts.js +0 -1
- package/dist/shared/icons.d.ts +46 -0
- package/dist/shared/icons.js +0 -1
- package/dist/shared/protocol.d.ts +28 -0
- package/dist/shared/protocol.js +0 -1
- package/dist/shared/signals.d.ts +37 -0
- package/dist/shared/signals.js +0 -1
- package/package.json +10 -11
- package/dist/index.js.map +0 -7
- package/dist/protocols/index.js.map +0 -7
- package/dist/protocols/rdp/buffer.js.map +0 -7
- package/dist/protocols/rdp/caps.js.map +0 -7
- package/dist/protocols/rdp/cert.js.map +0 -7
- package/dist/protocols/rdp/client.js.map +0 -7
- package/dist/protocols/rdp/cliprdr.js.map +0 -7
- package/dist/protocols/rdp/credssp.js.map +0 -7
- package/dist/protocols/rdp/crypto.js.map +0 -7
- package/dist/protocols/rdp/display.js.map +0 -7
- package/dist/protocols/rdp/gcc.js.map +0 -7
- package/dist/protocols/rdp/keymap.js.map +0 -7
- package/dist/protocols/rdp/mcs.js.map +0 -7
- package/dist/protocols/rdp/ntlm.js.map +0 -7
- package/dist/protocols/rdp/pdu.js.map +0 -7
- package/dist/protocols/rdp/rail.js.map +0 -7
- package/dist/protocols/rdp/rle.js.map +0 -7
- package/dist/protocols/rdp/sec.js.map +0 -7
- package/dist/protocols/rdp/session.js.map +0 -7
- package/dist/protocols/rdp/tls.js.map +0 -7
- package/dist/protocols/rdp/vchannel.js.map +0 -7
- package/dist/protocols/rdp/x224.js.map +0 -7
- package/dist/protocols/ssh/kex.js.map +0 -7
- package/dist/protocols/ssh/messages.js.map +0 -7
- package/dist/protocols/ssh/session.js.map +0 -7
- package/dist/protocols/ssh/terminal.js.map +0 -7
- package/dist/protocols/ssh/transport.js.map +0 -7
- package/dist/protocols/ssh/wire.js.map +0 -7
- package/dist/protocols/types.js.map +0 -7
- package/dist/protocols/vnc/input.js.map +0 -7
- package/dist/protocols/vnc/session.js.map +0 -7
- package/dist/shared/connection.js.map +0 -7
- package/dist/shared/device.js.map +0 -7
- package/dist/shared/hosts.js.map +0 -7
- package/dist/shared/icons.js.map +0 -7
- package/dist/shared/protocol.js.map +0 -7
- package/dist/shared/signals.js.map +0 -7
- package/src/cli/cli.ts +0 -462
- package/src/cli/daemon.ts +0 -306
- package/src/cli/framebuffer.ts +0 -115
- package/src/cli/generated/viewer-bundle.ts +0 -5
- package/src/cli/ipc.ts +0 -78
- package/src/cli/native/window.swift +0 -151
- package/src/cli/paths.ts +0 -28
- package/src/cli/png.ts +0 -106
- package/src/cli/session.ts +0 -263
- package/src/cli/shell.ts +0 -184
- package/src/cli/transport.ts +0 -86
- package/src/cli/viewer-client/assets.d.ts +0 -4
- package/src/cli/viewer-client/main.ts +0 -323
- package/src/cli/viewer-page.ts +0 -138
- package/src/cli/viewer.ts +0 -296
- package/src/cli/window.ts +0 -140
- package/src/cli/wsserver.ts +0 -144
- package/src/protocols/index.ts +0 -72
- package/src/protocols/rdp/buffer.ts +0 -204
- package/src/protocols/rdp/caps.ts +0 -304
- package/src/protocols/rdp/cert.ts +0 -190
- package/src/protocols/rdp/client.ts +0 -1371
- package/src/protocols/rdp/cliprdr.ts +0 -344
- package/src/protocols/rdp/credssp.ts +0 -319
- package/src/protocols/rdp/crypto.ts +0 -416
- package/src/protocols/rdp/display.ts +0 -750
- package/src/protocols/rdp/gcc.ts +0 -162
- package/src/protocols/rdp/keymap.ts +0 -68
- package/src/protocols/rdp/mcs.ts +0 -283
- package/src/protocols/rdp/ntlm.ts +0 -380
- package/src/protocols/rdp/pdu.ts +0 -497
- package/src/protocols/rdp/rail.ts +0 -480
- package/src/protocols/rdp/rle.ts +0 -343
- package/src/protocols/rdp/sec.ts +0 -184
- package/src/protocols/rdp/session.ts +0 -393
- package/src/protocols/rdp/tls.ts +0 -452
- package/src/protocols/rdp/vchannel.ts +0 -89
- package/src/protocols/rdp/x224.ts +0 -139
- package/src/protocols/ssh/kex.ts +0 -227
- package/src/protocols/ssh/messages.ts +0 -54
- package/src/protocols/ssh/session.ts +0 -879
- package/src/protocols/ssh/terminal.ts +0 -242
- package/src/protocols/ssh/transport.ts +0 -745
- package/src/protocols/ssh/wire.ts +0 -220
- package/src/protocols/types.ts +0 -146
- package/src/protocols/vnc/input.ts +0 -13
- package/src/protocols/vnc/novnc.d.ts +0 -75
- package/src/protocols/vnc/session.ts +0 -367
- package/src/shared/connection.ts +0 -206
- package/src/shared/device.ts +0 -28
- package/src/shared/hosts.ts +0 -240
- package/src/shared/icons.ts +0 -49
- package/src/shared/protocol.ts +0 -55
- 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
|
-
}
|
package/src/cli/session.ts
DELETED
|
@@ -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
|
-
}
|