ai-remote 0.1.0 → 0.3.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.
- package/dist/cli.mjs +8065 -0
- package/dist/index.js +6 -6
- package/dist/protocols/index.js +2 -2
- package/dist/protocols/rdp/caps.js +1 -1
- package/dist/protocols/rdp/cert.js +1 -1
- package/dist/protocols/rdp/client.js +18 -16
- package/dist/protocols/rdp/client.js.map +2 -2
- package/dist/protocols/rdp/cliprdr.js +1 -1
- package/dist/protocols/rdp/credssp.js +2 -2
- package/dist/protocols/rdp/display.js +2 -2
- package/dist/protocols/rdp/gcc.js +2 -2
- package/dist/protocols/rdp/mcs.js +2 -2
- package/dist/protocols/rdp/ntlm.js +2 -2
- package/dist/protocols/rdp/pdu.js +1 -1
- package/dist/protocols/rdp/rail.js +1 -1
- package/dist/protocols/rdp/sec.js +3 -3
- package/dist/protocols/rdp/session.js +3 -3
- package/dist/protocols/rdp/tls.js +3 -3
- package/dist/protocols/rdp/vchannel.js +1 -1
- package/dist/protocols/rdp/x224.js +1 -1
- package/dist/protocols/ssh/kex.js +1 -1
- package/dist/protocols/ssh/session.js +4 -3
- package/dist/protocols/ssh/session.js.map +2 -2
- package/dist/protocols/ssh/transport.js +8 -6
- package/dist/protocols/ssh/transport.js.map +2 -2
- package/dist/protocols/vnc/session.js +3 -3
- package/dist/shared/connection.js +1 -1
- package/dist/shared/hosts.js +1 -1
- package/package.json +13 -3
- package/src/cli/cli.ts +451 -0
- package/src/cli/daemon.ts +291 -0
- package/src/cli/framebuffer.ts +115 -0
- package/src/cli/generated/viewer-bundle.ts +5 -0
- package/src/cli/ipc.ts +78 -0
- package/src/cli/paths.ts +28 -0
- package/src/cli/png.ts +106 -0
- package/src/cli/session.ts +263 -0
- package/src/cli/shell.ts +171 -0
- package/src/cli/transport.ts +86 -0
- package/src/cli/viewer-client/assets.d.ts +4 -0
- package/src/cli/viewer-client/main.ts +300 -0
- package/src/cli/viewer-page.ts +102 -0
- package/src/cli/viewer.ts +269 -0
- package/src/cli/wsserver.ts +144 -0
- package/src/protocols/rdp/client.ts +9 -1
- package/src/protocols/ssh/session.ts +1 -0
- package/src/protocols/ssh/transport.ts +15 -2
package/src/cli/ipc.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
}
|
package/src/cli/paths.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
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
|
+
}
|
package/src/cli/shell.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An SSH shell on the same machine the desktop is showing.
|
|
3
|
+
*
|
|
4
|
+
* RDP has no shell channel, so a terminal beside the desktop is a second
|
|
5
|
+
* connection to the same host. The engine is the browser's, unchanged: the key
|
|
6
|
+
* exchange, the session keys and the password exist only in this process.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SshSession } from '../protocols/ssh/session';
|
|
10
|
+
import { TcpTransport } from './transport';
|
|
11
|
+
|
|
12
|
+
export interface ShellOptions {
|
|
13
|
+
host: string;
|
|
14
|
+
port: number;
|
|
15
|
+
username: string;
|
|
16
|
+
password: string;
|
|
17
|
+
columns?: number;
|
|
18
|
+
rows?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CommandResult {
|
|
22
|
+
output: string;
|
|
23
|
+
exitStatus: number | null;
|
|
24
|
+
timedOut: boolean;
|
|
25
|
+
durationMs: number | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class Shell {
|
|
29
|
+
readonly options: ShellOptions;
|
|
30
|
+
session: any;
|
|
31
|
+
ready = false;
|
|
32
|
+
lastError = '';
|
|
33
|
+
|
|
34
|
+
#sinks = new Set<(bytes: Uint8Array) => void>();
|
|
35
|
+
/** What the shell has printed, so a viewer attaching late sees the scrollback. */
|
|
36
|
+
#scrollback: Uint8Array[] = [];
|
|
37
|
+
#scrollbackBytes = 0;
|
|
38
|
+
|
|
39
|
+
constructor(options: ShellOptions) {
|
|
40
|
+
this.options = options;
|
|
41
|
+
|
|
42
|
+
this.session = new SshSession(`tcp://${options.host}:${options.port}`, {
|
|
43
|
+
username: options.username,
|
|
44
|
+
password: options.password,
|
|
45
|
+
columns: options.columns ?? 120,
|
|
46
|
+
rows: options.rows ?? 30,
|
|
47
|
+
// Every host this reaches was named by the person running the command,
|
|
48
|
+
// on their own network. There is no stored known_hosts to compare with
|
|
49
|
+
// yet, so nothing useful can be decided here.
|
|
50
|
+
verifyHost: async () => true,
|
|
51
|
+
openTransport: () => new TcpTransport(options.host, options.port),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
this.session.addEventListener('data', (event: any) => {
|
|
55
|
+
// `display` is what the terminal should show: the same stream with the
|
|
56
|
+
// framing `runCommand` wraps around a command filtered back out. `bytes`
|
|
57
|
+
// is the unfiltered original, which would leak those markers on screen.
|
|
58
|
+
const bytes: Uint8Array = event.detail.display ?? event.detail.bytes;
|
|
59
|
+
if (!bytes?.length) return;
|
|
60
|
+
this.#remember(bytes);
|
|
61
|
+
for (const sink of this.#sinks) sink(bytes);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this.session.addEventListener('ready', () => { this.ready = true; });
|
|
65
|
+
this.session.addEventListener('error', (event: any) => {
|
|
66
|
+
this.lastError = event.detail?.message || '';
|
|
67
|
+
});
|
|
68
|
+
this.session.addEventListener('close', () => { this.ready = false; });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Everything printed so far, capped so a long-running shell cannot grow forever. */
|
|
72
|
+
get scrollback(): Uint8Array {
|
|
73
|
+
const total = this.#scrollback.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
74
|
+
const out = new Uint8Array(total);
|
|
75
|
+
let offset = 0;
|
|
76
|
+
for (const chunk of this.#scrollback) { out.set(chunk, offset); offset += chunk.length; }
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onData(sink: (bytes: Uint8Array) => void): () => void {
|
|
81
|
+
this.#sinks.add(sink);
|
|
82
|
+
return () => this.#sinks.delete(sink);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
connect(timeoutMs = 30_000): Promise<void> {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
let settled = false;
|
|
88
|
+
const finish = (error?: Error) => {
|
|
89
|
+
if (settled) return;
|
|
90
|
+
settled = true;
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
if (error) reject(error); else resolve();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const timer = setTimeout(
|
|
96
|
+
() => finish(new Error(`The SSH host did not open a shell within ${Math.round(timeoutMs / 1000)}s.`)),
|
|
97
|
+
timeoutMs
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
this.session.addEventListener('ready', () => finish());
|
|
101
|
+
this.session.addEventListener('close', (event: any) => {
|
|
102
|
+
finish(new Error(this.lastError || event.detail?.message || 'The SSH connection closed during sign-in.'));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
this.session.connect();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Run one command and wait for its output and exit status.
|
|
111
|
+
*
|
|
112
|
+
* The first command against a Windows host usually fails: the engine frames
|
|
113
|
+
* commands POSIX-style to find where the output starts and ends, cmd.exe
|
|
114
|
+
* rejects that, and the engine notes the shell family and asks to be called
|
|
115
|
+
* again. That is a detail of how the shell was discovered, not something a
|
|
116
|
+
* caller did wrong, so the retry happens here instead of reaching them.
|
|
117
|
+
*/
|
|
118
|
+
async runCommand(command: string): Promise<CommandResult> {
|
|
119
|
+
let result;
|
|
120
|
+
try {
|
|
121
|
+
result = await this.session.runCommand(command);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
124
|
+
if (!/run the command again|not a POSIX shell/i.test(message)) throw error;
|
|
125
|
+
|
|
126
|
+
// Discovering the shell family restarts it, so the retry has to wait for
|
|
127
|
+
// the prompt to come back rather than firing straight into a shell that
|
|
128
|
+
// is still opening.
|
|
129
|
+
await this.#waitForPrompt();
|
|
130
|
+
result = await this.session.runCommand(command);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
output: result.output ?? '',
|
|
134
|
+
exitStatus: result.exitStatus ?? null,
|
|
135
|
+
timedOut: Boolean(result.timedOut),
|
|
136
|
+
durationMs: result.durationMs ?? null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Wait until the shell will accept a framed command again. */
|
|
141
|
+
async #waitForPrompt(timeoutMs = 15_000): Promise<void> {
|
|
142
|
+
const deadline = Date.now() + timeoutMs;
|
|
143
|
+
while (Date.now() < deadline) {
|
|
144
|
+
if (this.session.shellOpen && this.session.readySignaled !== false) return;
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
146
|
+
}
|
|
147
|
+
throw new Error('The SSH shell did not come back after its family was discovered.');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Raw keystrokes, for an interactive terminal. */
|
|
151
|
+
write(text: string): void {
|
|
152
|
+
this.session.write(text);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
resize(columns: number, rows: number): void {
|
|
156
|
+
this.session.resize?.(columns, rows);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
disconnect(): void {
|
|
160
|
+
try { this.session.disconnect(); } catch { /* already gone */ }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#remember(bytes: Uint8Array): void {
|
|
164
|
+
const CAP = 256 * 1024;
|
|
165
|
+
this.#scrollback.push(bytes);
|
|
166
|
+
this.#scrollbackBytes += bytes.length;
|
|
167
|
+
while (this.#scrollbackBytes > CAP && this.#scrollback.length > 1) {
|
|
168
|
+
this.#scrollbackBytes -= this.#scrollback.shift()!.length;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|