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/shell.ts
DELETED
|
@@ -1,184 +0,0 @@
|
|
|
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
|
-
/**
|
|
13
|
-
* The account name on its own.
|
|
14
|
-
*
|
|
15
|
-
* An RDP username may carry the machine or domain with it -- `DESKTOP-01\\owner`
|
|
16
|
-
* or `owner@corp` -- and SSH wants neither. The terminal defaults to the same
|
|
17
|
-
* account as the desktop, so this is what makes that default work rather than
|
|
18
|
-
* fail on a name that was perfectly good for RDP.
|
|
19
|
-
*/
|
|
20
|
-
export function bareUsername(username: string): string {
|
|
21
|
-
const withoutDomain = username.includes('\\') ? username.split('\\').pop()! : username;
|
|
22
|
-
return withoutDomain.split('@')[0].trim();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface ShellOptions {
|
|
26
|
-
host: string;
|
|
27
|
-
port: number;
|
|
28
|
-
username: string;
|
|
29
|
-
password: string;
|
|
30
|
-
columns?: number;
|
|
31
|
-
rows?: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface CommandResult {
|
|
35
|
-
output: string;
|
|
36
|
-
exitStatus: number | null;
|
|
37
|
-
timedOut: boolean;
|
|
38
|
-
durationMs: number | null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export class Shell {
|
|
42
|
-
readonly options: ShellOptions;
|
|
43
|
-
session: any;
|
|
44
|
-
ready = false;
|
|
45
|
-
lastError = '';
|
|
46
|
-
|
|
47
|
-
#sinks = new Set<(bytes: Uint8Array) => void>();
|
|
48
|
-
/** What the shell has printed, so a viewer attaching late sees the scrollback. */
|
|
49
|
-
#scrollback: Uint8Array[] = [];
|
|
50
|
-
#scrollbackBytes = 0;
|
|
51
|
-
|
|
52
|
-
constructor(options: ShellOptions) {
|
|
53
|
-
this.options = options;
|
|
54
|
-
|
|
55
|
-
this.session = new SshSession(`tcp://${options.host}:${options.port}`, {
|
|
56
|
-
username: options.username,
|
|
57
|
-
password: options.password,
|
|
58
|
-
columns: options.columns ?? 120,
|
|
59
|
-
rows: options.rows ?? 30,
|
|
60
|
-
// Every host this reaches was named by the person running the command,
|
|
61
|
-
// on their own network. There is no stored known_hosts to compare with
|
|
62
|
-
// yet, so nothing useful can be decided here.
|
|
63
|
-
verifyHost: async () => true,
|
|
64
|
-
openTransport: () => new TcpTransport(options.host, options.port),
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
this.session.addEventListener('data', (event: any) => {
|
|
68
|
-
// `display` is what the terminal should show: the same stream with the
|
|
69
|
-
// framing `runCommand` wraps around a command filtered back out. `bytes`
|
|
70
|
-
// is the unfiltered original, which would leak those markers on screen.
|
|
71
|
-
const bytes: Uint8Array = event.detail.display ?? event.detail.bytes;
|
|
72
|
-
if (!bytes?.length) return;
|
|
73
|
-
this.#remember(bytes);
|
|
74
|
-
for (const sink of this.#sinks) sink(bytes);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
this.session.addEventListener('ready', () => { this.ready = true; });
|
|
78
|
-
this.session.addEventListener('error', (event: any) => {
|
|
79
|
-
this.lastError = event.detail?.message || '';
|
|
80
|
-
});
|
|
81
|
-
this.session.addEventListener('close', () => { this.ready = false; });
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Everything printed so far, capped so a long-running shell cannot grow forever. */
|
|
85
|
-
get scrollback(): Uint8Array {
|
|
86
|
-
const total = this.#scrollback.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
87
|
-
const out = new Uint8Array(total);
|
|
88
|
-
let offset = 0;
|
|
89
|
-
for (const chunk of this.#scrollback) { out.set(chunk, offset); offset += chunk.length; }
|
|
90
|
-
return out;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
onData(sink: (bytes: Uint8Array) => void): () => void {
|
|
94
|
-
this.#sinks.add(sink);
|
|
95
|
-
return () => this.#sinks.delete(sink);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
connect(timeoutMs = 30_000): Promise<void> {
|
|
99
|
-
return new Promise((resolve, reject) => {
|
|
100
|
-
let settled = false;
|
|
101
|
-
const finish = (error?: Error) => {
|
|
102
|
-
if (settled) return;
|
|
103
|
-
settled = true;
|
|
104
|
-
clearTimeout(timer);
|
|
105
|
-
if (error) reject(error); else resolve();
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
const timer = setTimeout(
|
|
109
|
-
() => finish(new Error(`The SSH host did not open a shell within ${Math.round(timeoutMs / 1000)}s.`)),
|
|
110
|
-
timeoutMs
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
this.session.addEventListener('ready', () => finish());
|
|
114
|
-
this.session.addEventListener('close', (event: any) => {
|
|
115
|
-
finish(new Error(this.lastError || event.detail?.message || 'The SSH connection closed during sign-in.'));
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
this.session.connect();
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Run one command and wait for its output and exit status.
|
|
124
|
-
*
|
|
125
|
-
* The first command against a Windows host usually fails: the engine frames
|
|
126
|
-
* commands POSIX-style to find where the output starts and ends, cmd.exe
|
|
127
|
-
* rejects that, and the engine notes the shell family and asks to be called
|
|
128
|
-
* again. That is a detail of how the shell was discovered, not something a
|
|
129
|
-
* caller did wrong, so the retry happens here instead of reaching them.
|
|
130
|
-
*/
|
|
131
|
-
async runCommand(command: string): Promise<CommandResult> {
|
|
132
|
-
let result;
|
|
133
|
-
try {
|
|
134
|
-
result = await this.session.runCommand(command);
|
|
135
|
-
} catch (error) {
|
|
136
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
137
|
-
if (!/run the command again|not a POSIX shell/i.test(message)) throw error;
|
|
138
|
-
|
|
139
|
-
// Discovering the shell family restarts it, so the retry has to wait for
|
|
140
|
-
// the prompt to come back rather than firing straight into a shell that
|
|
141
|
-
// is still opening.
|
|
142
|
-
await this.#waitForPrompt();
|
|
143
|
-
result = await this.session.runCommand(command);
|
|
144
|
-
}
|
|
145
|
-
return {
|
|
146
|
-
output: result.output ?? '',
|
|
147
|
-
exitStatus: result.exitStatus ?? null,
|
|
148
|
-
timedOut: Boolean(result.timedOut),
|
|
149
|
-
durationMs: result.durationMs ?? null,
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Wait until the shell will accept a framed command again. */
|
|
154
|
-
async #waitForPrompt(timeoutMs = 15_000): Promise<void> {
|
|
155
|
-
const deadline = Date.now() + timeoutMs;
|
|
156
|
-
while (Date.now() < deadline) {
|
|
157
|
-
if (this.session.shellOpen && this.session.readySignaled !== false) return;
|
|
158
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
159
|
-
}
|
|
160
|
-
throw new Error('The SSH shell did not come back after its family was discovered.');
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Raw keystrokes, for an interactive terminal. */
|
|
164
|
-
write(text: string): void {
|
|
165
|
-
this.session.write(text);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
resize(columns: number, rows: number): void {
|
|
169
|
-
this.session.resize?.(columns, rows);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
disconnect(): void {
|
|
173
|
-
try { this.session.disconnect(); } catch { /* already gone */ }
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
#remember(bytes: Uint8Array): void {
|
|
177
|
-
const CAP = 256 * 1024;
|
|
178
|
-
this.#scrollback.push(bytes);
|
|
179
|
-
this.#scrollbackBytes += bytes.length;
|
|
180
|
-
while (this.#scrollbackBytes > CAP && this.#scrollback.length > 1) {
|
|
181
|
-
this.#scrollbackBytes -= this.#scrollback.shift()!.length;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
package/src/cli/transport.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A TCP socket wearing the shape of a WebSocket.
|
|
3
|
-
*
|
|
4
|
-
* The protocol engines were written against a browser WebSocket pointed at the
|
|
5
|
-
* gateway's relay. What they actually use of it is small -- open, message,
|
|
6
|
-
* close, error, send, readyState, close() -- so a direct TCP connection can
|
|
7
|
-
* present the same face and the engines never learn which one they were given.
|
|
8
|
-
*
|
|
9
|
-
* The one asymmetry is the gateway's own control frames. GATEWAY_READY and
|
|
10
|
-
* GATEWAY_KEEPALIVE are a conversation between the browser and the relay, and
|
|
11
|
-
* they are strings precisely so they can never be mistaken for protocol bytes.
|
|
12
|
-
* There is no relay here, so they are dropped: forwarding them would push
|
|
13
|
-
* `VNC_GATEWAY_READY_v1` into the middle of an X.224 negotiation.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import net from 'node:net';
|
|
17
|
-
|
|
18
|
-
export class TcpTransport extends EventTarget {
|
|
19
|
-
static readonly CONNECTING = 0;
|
|
20
|
-
static readonly OPEN = 1;
|
|
21
|
-
static readonly CLOSING = 2;
|
|
22
|
-
static readonly CLOSED = 3;
|
|
23
|
-
|
|
24
|
-
readyState = 0;
|
|
25
|
-
/** Set by the engines; this transport always delivers ArrayBuffers. */
|
|
26
|
-
binaryType = 'arraybuffer';
|
|
27
|
-
|
|
28
|
-
#socket: net.Socket;
|
|
29
|
-
#closeSent = false;
|
|
30
|
-
|
|
31
|
-
constructor(host: string, port: number) {
|
|
32
|
-
super();
|
|
33
|
-
|
|
34
|
-
this.#socket = net.connect({ host, port, noDelay: true });
|
|
35
|
-
|
|
36
|
-
this.#socket.on('connect', () => {
|
|
37
|
-
this.readyState = 1;
|
|
38
|
-
this.dispatchEvent(new Event('open'));
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
this.#socket.on('data', (chunk: Buffer) => {
|
|
42
|
-
// A copy, because Node reuses its read buffer and the engines hold on to
|
|
43
|
-
// what they are handed until a frame is complete.
|
|
44
|
-
const bytes = new Uint8Array(chunk.byteLength);
|
|
45
|
-
bytes.set(chunk);
|
|
46
|
-
this.dispatchEvent(new MessageEvent('message', { data: bytes.buffer }));
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
this.#socket.on('error', (error: Error) => {
|
|
50
|
-
this.dispatchEvent(new Event('error'));
|
|
51
|
-
this.#reportClose(1006, error.message, false);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
this.#socket.on('close', () => {
|
|
55
|
-
this.#reportClose(1006, 'The host closed the TCP connection.', false);
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
send(data: string | Uint8Array | ArrayBuffer): void {
|
|
60
|
-
// Gateway control frames. There is no gateway.
|
|
61
|
-
if (typeof data === 'string') return;
|
|
62
|
-
if (this.readyState !== 1) return;
|
|
63
|
-
this.#socket.write(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
close(code = 1000, reason = ''): void {
|
|
67
|
-
if (this.readyState >= 2) return;
|
|
68
|
-
this.readyState = 2;
|
|
69
|
-
this.#socket.destroy();
|
|
70
|
-
this.#reportClose(code, reason, code === 1000);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
#reportClose(code: number, reason: string, wasClean: boolean): void {
|
|
74
|
-
if (this.#closeSent) return;
|
|
75
|
-
this.#closeSent = true;
|
|
76
|
-
this.readyState = 3;
|
|
77
|
-
|
|
78
|
-
// CloseEvent is not a Node global. The engines read exactly these three
|
|
79
|
-
// fields, so a plain Event carrying them is indistinguishable to them.
|
|
80
|
-
const event = new Event('close') as Event & { code: number; reason: string; wasClean: boolean };
|
|
81
|
-
event.code = code;
|
|
82
|
-
event.reason = reason;
|
|
83
|
-
event.wasClean = wasClean;
|
|
84
|
-
this.dispatchEvent(event);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
@@ -1,323 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The viewer front end: the desktop, and a terminal beside it.
|
|
3
|
-
*
|
|
4
|
-
* This is a real bundle rather than a hand-written script in a string, for one
|
|
5
|
-
* reason: a Windows console addresses the cursor absolutely -- conhost emits
|
|
6
|
-
* `ESC[9;1H` between every line of output -- so anything less than a genuine
|
|
7
|
-
* terminal emulator renders its output scrambled. xterm.js is that emulator,
|
|
8
|
-
* and the web front end in this repository already uses it.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { Terminal } from '@xterm/xterm';
|
|
12
|
-
import { FitAddon } from '@xterm/addon-fit';
|
|
13
|
-
import '@xterm/xterm/css/xterm.css';
|
|
14
|
-
|
|
15
|
-
type Json = Record<string, any>;
|
|
16
|
-
|
|
17
|
-
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
|
18
|
-
|
|
19
|
-
const canvas = $<HTMLCanvasElement>('screen');
|
|
20
|
-
const context = canvas.getContext('2d', { alpha: false })!;
|
|
21
|
-
const stateEl = $('state');
|
|
22
|
-
const sizeEl = $('size');
|
|
23
|
-
const hostEl = $('host');
|
|
24
|
-
const scaleBtn = $('scale');
|
|
25
|
-
const ctlBtn = $('control');
|
|
26
|
-
const termBtn = $('term');
|
|
27
|
-
const fsBtn = $('full');
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* The toolbar has no room for a sentence, so a button's tooltip carries what a
|
|
31
|
-
* status line used to say.
|
|
32
|
-
*/
|
|
33
|
-
function tip(id: string, text: string): void {
|
|
34
|
-
const element = $(id);
|
|
35
|
-
element.title = text;
|
|
36
|
-
element.setAttribute('aria-label', text);
|
|
37
|
-
}
|
|
38
|
-
const termPane = $('terminal');
|
|
39
|
-
const termHost = $('termHost');
|
|
40
|
-
|
|
41
|
-
let control = false;
|
|
42
|
-
let painted = 0;
|
|
43
|
-
|
|
44
|
-
// The window hides its own titlebar on macOS and lets the page draw the bar,
|
|
45
|
-
// which means leaving room for the traffic lights that float over it.
|
|
46
|
-
if (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)) {
|
|
47
|
-
document.body.classList.add('macos');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const socket = new WebSocket(location.href.replace(/^http/, 'ws'));
|
|
51
|
-
socket.binaryType = 'arraybuffer';
|
|
52
|
-
|
|
53
|
-
const send = (message: Json) => {
|
|
54
|
-
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
// --- terminal ---------------------------------------------------------------
|
|
58
|
-
|
|
59
|
-
const terminal = new Terminal({
|
|
60
|
-
convertEol: false,
|
|
61
|
-
cursorBlink: true,
|
|
62
|
-
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
63
|
-
fontSize: 12,
|
|
64
|
-
scrollback: 5000,
|
|
65
|
-
theme: {
|
|
66
|
-
background: '#0d0f13',
|
|
67
|
-
foreground: '#d6dae3',
|
|
68
|
-
cursor: '#7ee2a8',
|
|
69
|
-
selectionBackground: '#2f4a7a',
|
|
70
|
-
},
|
|
71
|
-
});
|
|
72
|
-
const fitAddon = new FitAddon();
|
|
73
|
-
terminal.loadAddon(fitAddon);
|
|
74
|
-
|
|
75
|
-
let terminalOpened = false;
|
|
76
|
-
let terminalAttached = false;
|
|
77
|
-
|
|
78
|
-
function openTerminal(open: boolean): void {
|
|
79
|
-
termPane.classList.toggle('open', open);
|
|
80
|
-
termBtn.setAttribute('aria-pressed', String(open));
|
|
81
|
-
if (!open) return;
|
|
82
|
-
|
|
83
|
-
if (!terminalOpened) {
|
|
84
|
-
terminalOpened = true;
|
|
85
|
-
terminal.open(termHost);
|
|
86
|
-
// Keystrokes go straight to the shell, so the terminal behaves like a
|
|
87
|
-
// terminal: Ctrl-C interrupts, arrows reach the shell's own history.
|
|
88
|
-
terminal.onData((data) => {
|
|
89
|
-
if (!control) {
|
|
90
|
-
terminal.write('\r\n\x1b[33m[take control first — the terminal types on the remote machine]\x1b[m\r\n');
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
send({ type: 'shell-input', text: data });
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Two frames, because the pane has only just been given a size and the
|
|
98
|
-
// first frame measures the layout it had before that. Fitting too early
|
|
99
|
-
// computes one column, and a shell told it has one column wraps every line
|
|
100
|
-
// of its output into a vertical ribbon.
|
|
101
|
-
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
102
|
-
fitAddon.fit();
|
|
103
|
-
reportSize();
|
|
104
|
-
if (!terminalAttached) {
|
|
105
|
-
terminalAttached = true;
|
|
106
|
-
send({ type: 'shell-attach' });
|
|
107
|
-
}
|
|
108
|
-
terminal.focus();
|
|
109
|
-
}));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Tell the shell how wide its terminal is -- but never a size that cannot be
|
|
114
|
-
* real. A bad measurement is worse than a stale one: the shell reflows its
|
|
115
|
-
* output to whatever it is told.
|
|
116
|
-
*/
|
|
117
|
-
function reportSize(): void {
|
|
118
|
-
if (terminal.cols < 20 || terminal.rows < 5) return;
|
|
119
|
-
send({ type: 'shell-resize', columns: terminal.cols, rows: terminal.rows });
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
new ResizeObserver(() => {
|
|
123
|
-
if (!terminalOpened || !termPane.classList.contains('open')) return;
|
|
124
|
-
fitAddon.fit();
|
|
125
|
-
reportSize();
|
|
126
|
-
}).observe(termPane);
|
|
127
|
-
|
|
128
|
-
termBtn.addEventListener('click', () => openTerminal(termBtn.getAttribute('aria-pressed') !== 'true'));
|
|
129
|
-
|
|
130
|
-
// --- the desktop ------------------------------------------------------------
|
|
131
|
-
|
|
132
|
-
socket.addEventListener('open', () => {
|
|
133
|
-
stateEl.className = 'dot live';
|
|
134
|
-
stateEl.title = 'connected';
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
socket.addEventListener('close', () => {
|
|
138
|
-
stateEl.className = 'dot dead';
|
|
139
|
-
stateEl.title = 'the session ended';
|
|
140
|
-
terminal.write('\r\n\x1b[31m[the session ended]\x1b[m\r\n');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
socket.addEventListener('message', async (event) => {
|
|
144
|
-
if (typeof event.data !== 'string') {
|
|
145
|
-
await paint(new Uint8Array(event.data as ArrayBuffer));
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const message: Json = JSON.parse(event.data);
|
|
150
|
-
switch (message.type) {
|
|
151
|
-
case 'hello':
|
|
152
|
-
case 'resize':
|
|
153
|
-
resize(message);
|
|
154
|
-
if (message.host) hostEl.textContent = message.host;
|
|
155
|
-
break;
|
|
156
|
-
case 'control':
|
|
157
|
-
setControl(Boolean(message.on));
|
|
158
|
-
break;
|
|
159
|
-
case 'shell-data':
|
|
160
|
-
terminal.write(Uint8Array.from(atob(message.base64), (c) => c.charCodeAt(0)));
|
|
161
|
-
break;
|
|
162
|
-
case 'shell-status':
|
|
163
|
-
if (message.state === 'opening') terminal.write('\x1b[90m[opening a shell…]\x1b[m\r\n');
|
|
164
|
-
break;
|
|
165
|
-
case 'shell-error':
|
|
166
|
-
terminal.write(`\r\n\x1b[31m[${message.message}]\x1b[m\r\n`);
|
|
167
|
-
break;
|
|
168
|
-
default:
|
|
169
|
-
break;
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
function resize({ width, height }: Json): void {
|
|
174
|
-
if (!width || !height) return;
|
|
175
|
-
canvas.width = width;
|
|
176
|
-
canvas.height = height;
|
|
177
|
-
sizeEl.textContent = `${width}×${height}`;
|
|
178
|
-
document.title = `${hostEl.textContent} — ${width}×${height}`;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Inflate with the platform's own decompressor; no library involved. */
|
|
182
|
-
async function inflate(bytes: Uint8Array): Promise<Uint8Array> {
|
|
183
|
-
const stream = new Blob([bytes as BlobPart]).stream()
|
|
184
|
-
.pipeThrough(new DecompressionStream('deflate'));
|
|
185
|
-
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
async function paint(frame: Uint8Array): Promise<void> {
|
|
189
|
-
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
190
|
-
const left = view.getUint16(1, true);
|
|
191
|
-
const top = view.getUint16(3, true);
|
|
192
|
-
const width = view.getUint16(5, true);
|
|
193
|
-
const height = view.getUint16(7, true);
|
|
194
|
-
|
|
195
|
-
const rgba = await inflate(frame.subarray(9));
|
|
196
|
-
if (rgba.length < width * height * 4) return;
|
|
197
|
-
|
|
198
|
-
// The buffer came from a DecompressionStream, so it is a plain ArrayBuffer;
|
|
199
|
-
// the cast is only needed because a Uint8Array may in general be backed by a
|
|
200
|
-
// SharedArrayBuffer, which ImageData does not accept.
|
|
201
|
-
const pixels = new Uint8ClampedArray(rgba.buffer as ArrayBuffer, 0, width * height * 4);
|
|
202
|
-
context.putImageData(new ImageData(pixels, width, height), left, top);
|
|
203
|
-
|
|
204
|
-
if (++painted === 1) canvas.focus();
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// --- input ------------------------------------------------------------------
|
|
208
|
-
//
|
|
209
|
-
// Positions are in desktop pixels. The canvas may be scaled to fit, so every
|
|
210
|
-
// pointer event is mapped back through the element's real size.
|
|
211
|
-
|
|
212
|
-
function at(event: MouseEvent): { x: number; y: number } {
|
|
213
|
-
const box = canvas.getBoundingClientRect();
|
|
214
|
-
return {
|
|
215
|
-
x: Math.round((event.clientX - box.left) * (canvas.width / box.width)),
|
|
216
|
-
y: Math.round((event.clientY - box.top) * (canvas.height / box.height)),
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const desktop = (message: Json) => { if (control) send(message); };
|
|
221
|
-
|
|
222
|
-
canvas.addEventListener('mousemove', (e) => desktop({ type: 'move', ...at(e) }));
|
|
223
|
-
canvas.addEventListener('mousedown', (e) => {
|
|
224
|
-
e.preventDefault();
|
|
225
|
-
canvas.focus();
|
|
226
|
-
desktop({ type: 'button', ...at(e), button: e.button, pressed: true });
|
|
227
|
-
});
|
|
228
|
-
canvas.addEventListener('mouseup', (e) => {
|
|
229
|
-
e.preventDefault();
|
|
230
|
-
desktop({ type: 'button', ...at(e), button: e.button, pressed: false });
|
|
231
|
-
});
|
|
232
|
-
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
233
|
-
canvas.addEventListener('wheel', (e) => {
|
|
234
|
-
e.preventDefault();
|
|
235
|
-
desktop({ type: 'scroll', ...at(e), dx: Math.sign(e.deltaX), dy: Math.sign(e.deltaY) });
|
|
236
|
-
}, { passive: false });
|
|
237
|
-
|
|
238
|
-
canvas.addEventListener('keydown', (e) => {
|
|
239
|
-
if (!control) return;
|
|
240
|
-
e.preventDefault();
|
|
241
|
-
desktop({ type: 'key', code: e.code, pressed: true });
|
|
242
|
-
});
|
|
243
|
-
canvas.addEventListener('keyup', (e) => {
|
|
244
|
-
if (!control) return;
|
|
245
|
-
e.preventDefault();
|
|
246
|
-
desktop({ type: 'key', code: e.code, pressed: false });
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
// --- fullscreen -------------------------------------------------------------
|
|
250
|
-
//
|
|
251
|
-
// What a remote-desktop client does with the key: the desktop takes the whole
|
|
252
|
-
// display, the page chrome gets out of the way, and keystrokes go to the host
|
|
253
|
-
// rather than to the browser. Fit is forced on while it lasts, because a 1:1
|
|
254
|
-
// desktop larger than the screen would otherwise be a fullscreen scrollbar.
|
|
255
|
-
|
|
256
|
-
let fitBeforeFullscreen = false;
|
|
257
|
-
|
|
258
|
-
async function toggleFullscreen(): Promise<void> {
|
|
259
|
-
if (document.fullscreenElement) {
|
|
260
|
-
await document.exitFullscreen().catch(() => {});
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
fitBeforeFullscreen = scaleBtn.getAttribute('aria-pressed') === 'true';
|
|
264
|
-
await document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
document.addEventListener('fullscreenchange', () => {
|
|
268
|
-
const on = Boolean(document.fullscreenElement);
|
|
269
|
-
document.body.classList.toggle('fullscreen', on);
|
|
270
|
-
fsBtn.setAttribute('aria-pressed', String(on));
|
|
271
|
-
setFit(on ? true : fitBeforeFullscreen);
|
|
272
|
-
if (!termPane.classList.contains('open')) canvas.focus();
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
fsBtn.addEventListener('click', () => void toggleFullscreen());
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* F11 belongs to the host once it has the keyboard, so fullscreen is on a
|
|
279
|
-
* chord the remote desktop has no use for. Escape leaves, which is what every
|
|
280
|
-
* fullscreen surface does anyway.
|
|
281
|
-
*/
|
|
282
|
-
window.addEventListener('keydown', (event) => {
|
|
283
|
-
if (event.key === 'F11' && !control) { event.preventDefault(); void toggleFullscreen(); }
|
|
284
|
-
if (event.shiftKey && event.ctrlKey && event.code === 'KeyF') {
|
|
285
|
-
event.preventDefault();
|
|
286
|
-
void toggleFullscreen();
|
|
287
|
-
}
|
|
288
|
-
}, true);
|
|
289
|
-
|
|
290
|
-
// --- controls ---------------------------------------------------------------
|
|
291
|
-
|
|
292
|
-
function setFit(on: boolean): void {
|
|
293
|
-
scaleBtn.setAttribute('aria-pressed', String(on));
|
|
294
|
-
canvas.classList.toggle('fit', on);
|
|
295
|
-
tip('scale', on ? 'Showing scaled to fit — click for 100%' : 'Showing at 100% — click to scale to fit');
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
scaleBtn.addEventListener('click', () => setFit(scaleBtn.getAttribute('aria-pressed') !== 'true'));
|
|
299
|
-
|
|
300
|
-
// Ask; the server decides. The button reflects what came back, so it can never
|
|
301
|
-
// claim control the session has not actually granted.
|
|
302
|
-
ctlBtn.addEventListener('click', () => send({ type: 'control', on: !control }));
|
|
303
|
-
|
|
304
|
-
function setControl(on: boolean): void {
|
|
305
|
-
control = on;
|
|
306
|
-
ctlBtn.setAttribute('aria-pressed', String(on));
|
|
307
|
-
|
|
308
|
-
tip('control', on
|
|
309
|
-
? 'You have control — click to go back to watching'
|
|
310
|
-
: 'Watching. Click to send keyboard and pointer to the remote machine');
|
|
311
|
-
if (on && !termPane.classList.contains('open')) canvas.focus();
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
$('cad').addEventListener('click', () => {
|
|
315
|
-
if (!control) {
|
|
316
|
-
tip('cad', 'Take control first — Ctrl+Alt+Del is input like any other');
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
send({ type: 'cad' });
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
// The desktop starts at 100%, and the window is sized to hold it.
|
|
323
|
-
setFit(false);
|