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/viewer-page.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The viewer page: markup, styling, and an icon.
|
|
3
|
-
*
|
|
4
|
-
* The behaviour is a real bundle, built from src/cli/viewer-client and inlined
|
|
5
|
-
* here at build time. A published CLI has no asset directory it can rely on
|
|
6
|
-
* being unpacked beside it, so both arrive as strings.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { VIEWER_CSS, VIEWER_JS } from './generated/viewer-bundle';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* A screen with a cursor on it. Drawn as one inline SVG so it can be the
|
|
13
|
-
* favicon, the tab icon and the title-bar glyph without a file anywhere.
|
|
14
|
-
*/
|
|
15
|
-
const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">`
|
|
16
|
-
+ `<rect x="2" y="4" width="28" height="19" rx="3" fill="#1d2530" stroke="#4f8cf0" stroke-width="2"/>`
|
|
17
|
-
+ `<path d="M11 27h10M16 23v4" stroke="#4f8cf0" stroke-width="2" stroke-linecap="round"/>`
|
|
18
|
-
+ `<path d="M9 9l6 9 1.6-3.6L20 13z" fill="#7ee2a8"/>`
|
|
19
|
-
+ `</svg>`;
|
|
20
|
-
|
|
21
|
-
const FAVICON = `data:image/svg+xml,${encodeURIComponent(ICON)}`;
|
|
22
|
-
|
|
23
|
-
/** One toolbar button: an icon, a tooltip, and nothing else. */
|
|
24
|
-
const button = (id: string, title: string, path: string, pressed?: boolean) =>
|
|
25
|
-
`<button id="${id}" title="${title}" aria-label="${title}"`
|
|
26
|
-
+ `${pressed === undefined ? '' : ` aria-pressed="${pressed}"`}>`
|
|
27
|
-
+ `<svg viewBox="0 0 24 24" aria-hidden="true">${path}</svg></button>`;
|
|
28
|
-
|
|
29
|
-
// 24x24 stroke glyphs, so they all sit on the same optical weight.
|
|
30
|
-
const GLYPH = {
|
|
31
|
-
scale: '<path d="M4 9V5a1 1 0 011-1h4M20 15v4a1 1 0 01-1 1h-4M9 20H5a1 1 0 01-1-1v-4M15 4h4a1 1 0 011 1v4"/>',
|
|
32
|
-
fullscreen: '<path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/>',
|
|
33
|
-
control: '<path d="M6 3l12 7-5 1.5L15.5 17l-2 1-2.5-5.5L7 16z"/>',
|
|
34
|
-
terminal: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9l2.5 2.5L7 14M12.5 14.5H17"/>',
|
|
35
|
-
keys: '<rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/>',
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export const PAGE = `<!doctype html>
|
|
39
|
-
<html lang="en">
|
|
40
|
-
<head>
|
|
41
|
-
<meta charset="utf-8">
|
|
42
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
43
|
-
<title>ai-remote</title>
|
|
44
|
-
<link rel="icon" href="${FAVICON}">
|
|
45
|
-
<style>
|
|
46
|
-
:root { color-scheme: dark; }
|
|
47
|
-
* { box-sizing: border-box; }
|
|
48
|
-
body {
|
|
49
|
-
margin: 0; height: 100vh; display: flex; flex-direction: column;
|
|
50
|
-
background: #0f1116; color: #e6e8ee; overflow: hidden;
|
|
51
|
-
font: 12px/1.4 ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/* One compact strip: the icon, what this is, and the controls. Nothing
|
|
55
|
-
below the desktop, so the window is the desktop plus 34 pixels. */
|
|
56
|
-
header {
|
|
57
|
-
display: flex; align-items: center; gap: 6px; height: 34px; flex: 0 0 34px;
|
|
58
|
-
padding: 0 8px; background: #171a21; border-bottom: 1px solid #242a34;
|
|
59
|
-
-webkit-user-select: none; user-select: none;
|
|
60
|
-
}
|
|
61
|
-
/* The window's traffic lights are drawn over this strip, so the left end has
|
|
62
|
-
to get out of their way. Set from the platform by the viewer bundle. */
|
|
63
|
-
body.macos header { padding-left: 78px; }
|
|
64
|
-
/* The top of the strip is the system's drag region: content there belongs to
|
|
65
|
-
the window, not to the page, so nothing interactive is put in it. */
|
|
66
|
-
body.macos header .controls { align-self: flex-end; padding-bottom: 4px; }
|
|
67
|
-
header .mark { width: 16px; height: 16px; flex: 0 0 16px; opacity: .95; }
|
|
68
|
-
header .mark svg { width: 100%; height: 100%; display: block; }
|
|
69
|
-
header .where { color: #aeb6c4; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
70
|
-
header .where b { color: #e6e8ee; font-weight: 600; }
|
|
71
|
-
header .spacer { flex: 1; }
|
|
72
|
-
|
|
73
|
-
header .controls { display: flex; align-items: center; gap: 2px; }
|
|
74
|
-
.dot { width: 7px; height: 7px; border-radius: 50%; background: #5a6472; flex: 0 0 7px; }
|
|
75
|
-
.dot.live { background: #7ee2a8; box-shadow: 0 0 6px rgba(126,226,168,.7); }
|
|
76
|
-
.dot.dead { background: #ff8080; }
|
|
77
|
-
|
|
78
|
-
button {
|
|
79
|
-
display: grid; place-items: center; width: 26px; height: 24px; padding: 0;
|
|
80
|
-
color: #aeb6c4; background: none; border: 1px solid transparent;
|
|
81
|
-
border-radius: 5px; cursor: pointer;
|
|
82
|
-
}
|
|
83
|
-
button svg {
|
|
84
|
-
width: 15px; height: 15px; fill: none; stroke: currentColor;
|
|
85
|
-
stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
|
|
86
|
-
}
|
|
87
|
-
button#control svg { fill: currentColor; stroke: none; }
|
|
88
|
-
button:hover { color: #e6e8ee; background: #232936; }
|
|
89
|
-
button[aria-pressed="true"] { color: #fff; background: #2f5fb0; border-color: #3f74d0; }
|
|
90
|
-
|
|
91
|
-
main {
|
|
92
|
-
flex: 1; min-height: 0; display: grid; place-items: center;
|
|
93
|
-
overflow: auto; background: #07080b;
|
|
94
|
-
}
|
|
95
|
-
/* 100% by default: a remote pixel is a real pixel unless asked otherwise. */
|
|
96
|
-
canvas { display: block; background: #000; outline: none; image-rendering: auto; }
|
|
97
|
-
canvas.fit { max-width: 100%; max-height: 100%; object-fit: contain; }
|
|
98
|
-
|
|
99
|
-
#terminal {
|
|
100
|
-
display: none; min-height: 0; height: 38%; flex: 0 0 38%;
|
|
101
|
-
border-top: 1px solid #242a34; background: #0b0d11; padding: 4px 6px 0;
|
|
102
|
-
}
|
|
103
|
-
#terminal.open { display: block; }
|
|
104
|
-
#termHost { height: 100%; }
|
|
105
|
-
|
|
106
|
-
/* Fullscreen: the desktop and nothing else, with the strip a pointer-flick
|
|
107
|
-
away at the top edge rather than gone for good. */
|
|
108
|
-
body.fullscreen main { background: #000; }
|
|
109
|
-
body.fullscreen header {
|
|
110
|
-
position: fixed; inset: 0 0 auto 0; z-index: 10;
|
|
111
|
-
background: rgba(23, 26, 33, .95);
|
|
112
|
-
transform: translateY(-100%); transition: transform .16s ease;
|
|
113
|
-
}
|
|
114
|
-
body.fullscreen header:hover, body.fullscreen header:focus-within { transform: none; }
|
|
115
|
-
${VIEWER_CSS}
|
|
116
|
-
</style>
|
|
117
|
-
</head>
|
|
118
|
-
<body>
|
|
119
|
-
<header>
|
|
120
|
-
<span class="mark">${ICON}</span>
|
|
121
|
-
<span class="where"><b id="host">—</b> <span id="size"></span></span>
|
|
122
|
-
<span class="dot" id="state" title="connecting"></span>
|
|
123
|
-
<span class="spacer"></span>
|
|
124
|
-
<span class="controls">
|
|
125
|
-
${button('scale', 'Scale to fit / 100%', GLYPH.scale, false)}
|
|
126
|
-
${button('full', 'Fullscreen', GLYPH.fullscreen, false)}
|
|
127
|
-
${button('control', 'Take control', GLYPH.control, false)}
|
|
128
|
-
${button('term', 'Terminal', GLYPH.terminal, false)}
|
|
129
|
-
${button('cad', 'Ctrl+Alt+Del', GLYPH.keys)}
|
|
130
|
-
</span>
|
|
131
|
-
</header>
|
|
132
|
-
<main><canvas id="screen" tabindex="0"></canvas></main>
|
|
133
|
-
<section id="terminal"><div id="termHost"></div></section>
|
|
134
|
-
<script type="module">
|
|
135
|
-
${VIEWER_JS}
|
|
136
|
-
</script>
|
|
137
|
-
</body>
|
|
138
|
-
</html>`;
|
package/src/cli/viewer.ts
DELETED
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The window a human watches the session through.
|
|
3
|
-
*
|
|
4
|
-
* The session already exists and already holds the pixels; this is an
|
|
5
|
-
* attachment to it. Viewers may come and go, and there may be none at all --
|
|
6
|
-
* a viewer that could end a session would make supervision a dependency
|
|
7
|
-
* rather than a feature.
|
|
8
|
-
*
|
|
9
|
-
* A viewer that has just attached needs the whole desktop, not a delta, which
|
|
10
|
-
* is exactly what the framebuffer is for: it sends one keyframe on connect and
|
|
11
|
-
* painted rectangles after that.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { createServer } from 'node:http';
|
|
15
|
-
import type net from 'node:net';
|
|
16
|
-
import { randomBytes } from 'node:crypto';
|
|
17
|
-
import { deflateSync } from 'node:zlib';
|
|
18
|
-
import type { RdpSession } from './session';
|
|
19
|
-
import type { Shell } from './shell';
|
|
20
|
-
import { accept, type WebSocketPeer } from './wsserver';
|
|
21
|
-
import { openWindow, type WindowHandle } from './window';
|
|
22
|
-
import { PAGE } from './viewer-page';
|
|
23
|
-
|
|
24
|
-
export interface Viewer {
|
|
25
|
-
url: string;
|
|
26
|
-
/** Resolves when something has actually attached and asked for pixels. */
|
|
27
|
-
opened: Promise<void>;
|
|
28
|
-
/** How the window was opened, for the message the command prints. */
|
|
29
|
-
window: WindowHandle | null;
|
|
30
|
-
/** How many viewers are attached right now. Zero is a normal state. */
|
|
31
|
-
readonly viewers: number;
|
|
32
|
-
/** Open the window again, for one that was closed by hand. */
|
|
33
|
-
launch(): void;
|
|
34
|
-
close(): void;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Frame layout, little-endian:
|
|
39
|
-
* u8 kind 1 = keyframe, 2 = rectangle
|
|
40
|
-
* u16 left, u16 top, u16 width, u16 height
|
|
41
|
-
* deflated RGBA
|
|
42
|
-
*/
|
|
43
|
-
function encodeRect(kind: number, left: number, top: number, width: number, height: number, rgba: Uint8Array): Buffer {
|
|
44
|
-
const header = Buffer.alloc(9);
|
|
45
|
-
header.writeUInt8(kind, 0);
|
|
46
|
-
header.writeUInt16LE(left, 1);
|
|
47
|
-
header.writeUInt16LE(top, 3);
|
|
48
|
-
header.writeUInt16LE(width, 5);
|
|
49
|
-
header.writeUInt16LE(height, 7);
|
|
50
|
-
return Buffer.concat([header, deflateSync(rgba, { level: 1 })]);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function startViewer(
|
|
54
|
-
session: RdpSession,
|
|
55
|
-
port: number,
|
|
56
|
-
{
|
|
57
|
-
launch = true,
|
|
58
|
-
openShell,
|
|
59
|
-
tab = false,
|
|
60
|
-
fullscreen = false,
|
|
61
|
-
title,
|
|
62
|
-
}: {
|
|
63
|
-
launch?: boolean;
|
|
64
|
-
openShell?: () => Promise<Shell>;
|
|
65
|
-
tab?: boolean;
|
|
66
|
-
fullscreen?: boolean;
|
|
67
|
-
title?: string;
|
|
68
|
-
} = {}
|
|
69
|
-
): Promise<Viewer> {
|
|
70
|
-
const token = randomBytes(16).toString('hex');
|
|
71
|
-
const peers = new Set<WebSocketPeer>();
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Which viewers may send input.
|
|
75
|
-
*
|
|
76
|
-
* The gate lives here rather than in the page, because a gate in the page is
|
|
77
|
-
* not a gate: anything that can open a socket can skip it. A viewer attaches
|
|
78
|
-
* as an observer and has to ask for control, which is also the right default
|
|
79
|
-
* when the session was opened by an agent -- the human watches first.
|
|
80
|
-
*/
|
|
81
|
-
const drivers = new WeakSet<WebSocketPeer>();
|
|
82
|
-
|
|
83
|
-
/** Undoes each viewer's subscription to shell output when it goes away. */
|
|
84
|
-
const detachShell = new Map<WebSocketPeer, () => void>();
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Attach a viewer to the terminal, opening it if this is the first ask.
|
|
88
|
-
*
|
|
89
|
-
* The scrollback goes out first, so a viewer that opens the terminal ten
|
|
90
|
-
* minutes into a session sees what has already been run rather than an empty
|
|
91
|
-
* pane that looks broken.
|
|
92
|
-
*/
|
|
93
|
-
async function attachShell(peer: WebSocketPeer): Promise<void> {
|
|
94
|
-
if (!openShell) {
|
|
95
|
-
peer.send(JSON.stringify({
|
|
96
|
-
type: 'shell-error',
|
|
97
|
-
message: 'This session has no terminal available.',
|
|
98
|
-
}));
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
if (detachShell.has(peer)) return;
|
|
102
|
-
|
|
103
|
-
peer.send(JSON.stringify({ type: 'shell-status', state: 'opening' }));
|
|
104
|
-
let shell: Shell;
|
|
105
|
-
try {
|
|
106
|
-
shell = await openShell();
|
|
107
|
-
} catch (error) {
|
|
108
|
-
peer.send(JSON.stringify({
|
|
109
|
-
type: 'shell-error',
|
|
110
|
-
message: error instanceof Error ? error.message : String(error),
|
|
111
|
-
}));
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const history = shell.scrollback;
|
|
116
|
-
if (history.length) {
|
|
117
|
-
peer.send(JSON.stringify({ type: 'shell-data', base64: Buffer.from(history).toString('base64') }));
|
|
118
|
-
}
|
|
119
|
-
peer.send(JSON.stringify({ type: 'shell-status', state: 'open' }));
|
|
120
|
-
|
|
121
|
-
detachShell.set(peer, shell.onData((bytes) => {
|
|
122
|
-
peer.send(JSON.stringify({ type: 'shell-data', base64: Buffer.from(bytes).toString('base64') }));
|
|
123
|
-
}));
|
|
124
|
-
|
|
125
|
-
shellFor.set(peer, shell);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const shellFor = new Map<WebSocketPeer, Shell>();
|
|
129
|
-
|
|
130
|
-
let resolveOpened: () => void;
|
|
131
|
-
const opened = new Promise<void>((resolve) => { resolveOpened = resolve; });
|
|
132
|
-
|
|
133
|
-
const server = createServer((request, response) => {
|
|
134
|
-
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
135
|
-
if (url.pathname === '/' && url.searchParams.get('t') === token) {
|
|
136
|
-
response.writeHead(200, {
|
|
137
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
138
|
-
'Cache-Control': 'no-store',
|
|
139
|
-
});
|
|
140
|
-
response.end(PAGE);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
response.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
144
|
-
response.end('Not found');
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
server.on('upgrade', (request, socket) => {
|
|
148
|
-
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
149
|
-
if (url.searchParams.get('t') !== token) { socket.destroy(); return; }
|
|
150
|
-
|
|
151
|
-
// Node types an upgrade socket as Duplex; every runtime it has is a
|
|
152
|
-
// net.Socket, which is what lets the peer turn Nagle off.
|
|
153
|
-
const peer = accept(request, socket as net.Socket);
|
|
154
|
-
if (!peer) return;
|
|
155
|
-
peers.add(peer);
|
|
156
|
-
resolveOpened();
|
|
157
|
-
|
|
158
|
-
const { width, height } = session.framebuffer;
|
|
159
|
-
peer.send(JSON.stringify({ type: 'hello', width, height, host: session.options.host }));
|
|
160
|
-
if (width && height) {
|
|
161
|
-
peer.send(encodeRect(1, 0, 0, width, height, session.framebuffer.pixels));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
peer.addEventListener('message', (event: Event) => {
|
|
165
|
-
const raw = (event as MessageEvent).data as string;
|
|
166
|
-
if (process.env.REMOTECTL_DEBUG) console.log('[viewer] <-', raw);
|
|
167
|
-
try {
|
|
168
|
-
const message = JSON.parse(raw);
|
|
169
|
-
|
|
170
|
-
if (message.type === 'control') {
|
|
171
|
-
if (message.on) drivers.add(peer); else drivers.delete(peer);
|
|
172
|
-
peer.send(JSON.stringify({ type: 'control', on: Boolean(message.on) }));
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (message.type === 'shell-attach') { void attachShell(peer); return; }
|
|
177
|
-
|
|
178
|
-
if (!drivers.has(peer)) return;
|
|
179
|
-
|
|
180
|
-
if (message.type === 'shell-input') {
|
|
181
|
-
shellFor.get(peer)?.write(String(message.text ?? ''));
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
if (message.type === 'shell-resize') {
|
|
185
|
-
shellFor.get(peer)?.resize(Number(message.columns) || 120, Number(message.rows) || 30);
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
applyInput(session, message);
|
|
190
|
-
} catch (error) {
|
|
191
|
-
// A malformed frame from a viewer must never take the session down.
|
|
192
|
-
console.warn('[viewer] dropped an unreadable message', error);
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
peer.addEventListener('close', () => {
|
|
196
|
-
peers.delete(peer);
|
|
197
|
-
detachShell.get(peer)?.();
|
|
198
|
-
detachShell.delete(peer);
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Loopback only. The viewer carries a live desktop and takes input for it.
|
|
204
|
-
*
|
|
205
|
-
* A busy port is not a reason to fail: a second session, or a stale process
|
|
206
|
-
* holding the default, would otherwise take the whole connection down. The
|
|
207
|
-
* chosen port is reported back, so nothing has to guess which one it got.
|
|
208
|
-
*/
|
|
209
|
-
const bound = await new Promise<number>((resolve, reject) => {
|
|
210
|
-
const listen = (candidate: number, retry: boolean) => {
|
|
211
|
-
server.once('error', (error: NodeJS.ErrnoException) => {
|
|
212
|
-
if (retry && error.code === 'EADDRINUSE') { listen(0, false); return; }
|
|
213
|
-
reject(error);
|
|
214
|
-
});
|
|
215
|
-
server.listen(candidate, '127.0.0.1', () => {
|
|
216
|
-
const address = server.address();
|
|
217
|
-
resolve(typeof address === 'object' && address ? address.port : candidate);
|
|
218
|
-
});
|
|
219
|
-
};
|
|
220
|
-
listen(port, true);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
session.onResize((width, height) => {
|
|
224
|
-
for (const peer of peers) {
|
|
225
|
-
peer.send(JSON.stringify({ type: 'resize', width, height }));
|
|
226
|
-
peer.send(encodeRect(1, 0, 0, width, height, session.framebuffer.pixels));
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
session.onPaint((rects) => {
|
|
231
|
-
if (!peers.size) return;
|
|
232
|
-
for (const rect of rects) {
|
|
233
|
-
const payload = encodeRect(2, rect.left, rect.top, rect.width, rect.height, rect.rgba);
|
|
234
|
-
for (const peer of peers) peer.send(payload);
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
const url = `http://127.0.0.1:${bound}/?t=${token}`;
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* The window is sized to hold the desktop at 100% plus the toolbar strip, so
|
|
242
|
-
* nothing is scaled down until somebody asks for it.
|
|
243
|
-
*/
|
|
244
|
-
const TOOLBAR = 34;
|
|
245
|
-
const show = (): WindowHandle => openWindow({
|
|
246
|
-
url,
|
|
247
|
-
title: title ?? `ai-remote — ${session.options.host}`,
|
|
248
|
-
width: session.framebuffer.width || session.options.width,
|
|
249
|
-
height: (session.framebuffer.height || session.options.height) + TOOLBAR,
|
|
250
|
-
fullscreen,
|
|
251
|
-
tab,
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
let window = launch ? show() : null;
|
|
255
|
-
|
|
256
|
-
return {
|
|
257
|
-
url,
|
|
258
|
-
opened,
|
|
259
|
-
get window() { return window; },
|
|
260
|
-
get viewers() { return peers.size; },
|
|
261
|
-
launch() { window = show(); },
|
|
262
|
-
close() {
|
|
263
|
-
for (const peer of peers) peer.close();
|
|
264
|
-
server.close();
|
|
265
|
-
// The window is its own process; closing the server would leave it
|
|
266
|
-
// staring at a socket that is not there.
|
|
267
|
-
if (window?.pid) { try { process.kill(window.pid); } catch { /* already gone */ } }
|
|
268
|
-
window = null;
|
|
269
|
-
},
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function applyInput(session: RdpSession, message: any): void {
|
|
274
|
-
switch (message.type) {
|
|
275
|
-
case 'move':
|
|
276
|
-
session.movePointer(message.x, message.y);
|
|
277
|
-
break;
|
|
278
|
-
case 'button':
|
|
279
|
-
session.pointerButton(message.x, message.y, message.button ?? 0, Boolean(message.pressed));
|
|
280
|
-
break;
|
|
281
|
-
case 'scroll':
|
|
282
|
-
session.scroll(message.x, message.y, message.dx ?? 0, message.dy ?? 0);
|
|
283
|
-
break;
|
|
284
|
-
case 'key':
|
|
285
|
-
session.sendKeyCode(message.code, Boolean(message.pressed));
|
|
286
|
-
break;
|
|
287
|
-
case 'text':
|
|
288
|
-
session.typeText(String(message.text ?? ''));
|
|
289
|
-
break;
|
|
290
|
-
case 'cad':
|
|
291
|
-
session.sendCtrlAltDel();
|
|
292
|
-
break;
|
|
293
|
-
default:
|
|
294
|
-
break;
|
|
295
|
-
}
|
|
296
|
-
}
|
package/src/cli/window.ts
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A native window for the session — no browser, no bundled engine.
|
|
3
|
-
*
|
|
4
|
-
* The window and the webview inside it come from the operating system: WebView2
|
|
5
|
-
* on Windows, WebKit on macOS, WebKitGTK on Linux, reached through tao/wry. So
|
|
6
|
-
* the session gets a real application window with the platform's own title bar,
|
|
7
|
-
* its own dock or taskbar entry, and the green button on macOS meaning
|
|
8
|
-
* fullscreen — for about 4 MB rather than the 150 MB a second browser costs.
|
|
9
|
-
*
|
|
10
|
-
* It runs in a process of its own rather than inside the session, for the same
|
|
11
|
-
* reason the viewer is an attachment: a window has an event loop that wants the
|
|
12
|
-
* main thread, and closing one must not take a session with it.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { spawn } from 'node:child_process';
|
|
16
|
-
import { createRequire } from 'node:module';
|
|
17
|
-
|
|
18
|
-
/** Whether the native window layer is installed for this platform. */
|
|
19
|
-
export function nativeWindowAvailable(): boolean {
|
|
20
|
-
try {
|
|
21
|
-
createRequire(import.meta.url).resolve('@webviewjs/webview');
|
|
22
|
-
return true;
|
|
23
|
-
} catch {
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface WindowOptions {
|
|
29
|
-
url: string;
|
|
30
|
-
title: string;
|
|
31
|
-
/** The remote desktop's size; the window is sized to show it at 100%. */
|
|
32
|
-
width: number;
|
|
33
|
-
height: number;
|
|
34
|
-
fullscreen?: boolean;
|
|
35
|
-
/** Open in the default browser instead of a window of its own. */
|
|
36
|
-
tab?: boolean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface WindowHandle {
|
|
40
|
-
kind: 'window' | 'tab' | 'none';
|
|
41
|
-
pid?: number;
|
|
42
|
-
detail: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Open the viewer, and say what actually happened rather than throwing. A
|
|
47
|
-
* window that will not open is a worse session, not a failed one, and the URL
|
|
48
|
-
* has been printed either way.
|
|
49
|
-
*/
|
|
50
|
-
export function openWindow(options: WindowOptions): WindowHandle {
|
|
51
|
-
if (!options.tab && nativeWindowAvailable()) {
|
|
52
|
-
const args = [
|
|
53
|
-
process.argv[1],
|
|
54
|
-
'__window',
|
|
55
|
-
options.url,
|
|
56
|
-
'--title', options.title,
|
|
57
|
-
'--width', String(options.width),
|
|
58
|
-
'--height', String(options.height),
|
|
59
|
-
];
|
|
60
|
-
if (options.fullscreen) args.push('--fullscreen');
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
const child = spawn(process.execPath, args, { stdio: 'ignore', detached: true });
|
|
64
|
-
child.unref();
|
|
65
|
-
return { kind: 'window', pid: child.pid, detail: 'native window' };
|
|
66
|
-
} catch (error) {
|
|
67
|
-
return { kind: 'none', detail: error instanceof Error ? error.message : String(error) };
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const command = process.platform === 'darwin' ? 'open'
|
|
72
|
-
: process.platform === 'win32' ? 'cmd'
|
|
73
|
-
: 'xdg-open';
|
|
74
|
-
const args = process.platform === 'win32' ? ['/c', 'start', '', options.url] : [options.url];
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
spawn(command, args, { stdio: 'ignore', detached: true }).unref();
|
|
78
|
-
return {
|
|
79
|
-
kind: 'tab',
|
|
80
|
-
detail: options.tab ? 'asked for a browser tab' : 'the native window layer is not installed',
|
|
81
|
-
};
|
|
82
|
-
} catch (error) {
|
|
83
|
-
return { kind: 'none', detail: error instanceof Error ? error.message : String(error) };
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Run the window. This is the `__window` process, and it does not return. */
|
|
88
|
-
export async function runWindow(argv: string[]): Promise<void> {
|
|
89
|
-
const positional: string[] = [];
|
|
90
|
-
const flags: Record<string, string | boolean> = {};
|
|
91
|
-
for (let i = 0; i < argv.length; i++) {
|
|
92
|
-
const token = argv[i];
|
|
93
|
-
if (!token.startsWith('--')) { positional.push(token); continue; }
|
|
94
|
-
const name = token.slice(2);
|
|
95
|
-
if (name === 'fullscreen') flags[name] = true;
|
|
96
|
-
else flags[name] = argv[++i] ?? '';
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const url = positional[0];
|
|
100
|
-
if (!url) throw new Error('__window needs a URL');
|
|
101
|
-
|
|
102
|
-
const { Application } = await import('@webviewjs/webview');
|
|
103
|
-
const application = new Application();
|
|
104
|
-
|
|
105
|
-
// `logical: true` matters: without it these are device pixels, so a window
|
|
106
|
-
// asked for at 1280 opens at 640 on any Retina display.
|
|
107
|
-
const window = application.createBrowserWindow({
|
|
108
|
-
title: String(flags.title || 'ai-remote'),
|
|
109
|
-
width: Number(flags.width) || 1280,
|
|
110
|
-
height: Number(flags.height) || 800,
|
|
111
|
-
logical: true,
|
|
112
|
-
resizable: true,
|
|
113
|
-
maximizable: true,
|
|
114
|
-
minimizable: true,
|
|
115
|
-
focused: true,
|
|
116
|
-
|
|
117
|
-
// One bar, not two. The page draws the title and the controls, and the
|
|
118
|
-
// system titlebar becomes a transparent strip that the content runs
|
|
119
|
-
// underneath -- so the traffic lights sit on the page's own toolbar.
|
|
120
|
-
//
|
|
121
|
-
// Transparent rather than hidden on purpose: this binding exposes no way to
|
|
122
|
-
// start a window drag from the page, so removing the titlebar outright
|
|
123
|
-
// would leave a window nothing could move.
|
|
124
|
-
macosTitlebarTransparent: true,
|
|
125
|
-
macosFullsizeContentView: true,
|
|
126
|
-
macosTitleHidden: true,
|
|
127
|
-
// The page handles clicks in the bar, so this is what leaves the window
|
|
128
|
-
// draggable by the parts of it that are not a button.
|
|
129
|
-
macosMovableByWindowBackground: true,
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
window.createWebview({ url, enableDevtools: false });
|
|
133
|
-
|
|
134
|
-
if (flags.fullscreen) {
|
|
135
|
-
// After the window has a screen to fill.
|
|
136
|
-
setTimeout(() => { try { window.setFullscreen(); } catch { /* not fatal */ } }, 400);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
await application.whenReady();
|
|
140
|
-
}
|