ai-remote 0.3.1 → 0.4.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 +232 -81
- package/package.json +7 -4
- package/src/cli/cli.ts +13 -3
- package/src/cli/daemon.ts +13 -2
- package/src/cli/generated/viewer-bundle.ts +1 -1
- package/src/cli/native/window.swift +151 -0
- package/src/cli/viewer-client/main.ts +38 -15
- package/src/cli/viewer-page.ts +92 -56
- package/src/cli/viewer.ts +43 -19
- package/src/cli/window.ts +140 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// A standalone macOS window for the session viewer.
|
|
2
|
+
//
|
|
3
|
+
// No browser is involved: this is an NSWindow holding a WKWebView, which is the
|
|
4
|
+
// WebKit that ships with the system. So the session gets a real application
|
|
5
|
+
// window -- its own dock entry, the platform's own traffic lights, and the green
|
|
6
|
+
// button doing native fullscreen -- without Chrome, without Electron, and
|
|
7
|
+
// without a compiled dependency in the package.
|
|
8
|
+
//
|
|
9
|
+
// Built on first use by src/cli/native.ts and cached; the source is shipped
|
|
10
|
+
// rather than a binary, so nothing here has to be trusted or code-signed.
|
|
11
|
+
|
|
12
|
+
import AppKit
|
|
13
|
+
import WebKit
|
|
14
|
+
|
|
15
|
+
struct Options {
|
|
16
|
+
var url = ""
|
|
17
|
+
var title = "ai-remote"
|
|
18
|
+
var width: CGFloat = 1280
|
|
19
|
+
var height: CGFloat = 892
|
|
20
|
+
var fullscreen = false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func parse() -> Options {
|
|
24
|
+
var options = Options()
|
|
25
|
+
var arguments = Array(CommandLine.arguments.dropFirst())
|
|
26
|
+
|
|
27
|
+
while let argument = arguments.first {
|
|
28
|
+
arguments.removeFirst()
|
|
29
|
+
switch argument {
|
|
30
|
+
case "--title": options.title = arguments.isEmpty ? options.title : arguments.removeFirst()
|
|
31
|
+
case "--width": options.width = CGFloat(Double(arguments.removeFirst()) ?? 1280)
|
|
32
|
+
case "--height": options.height = CGFloat(Double(arguments.removeFirst()) ?? 892)
|
|
33
|
+
case "--fullscreen": options.fullscreen = true
|
|
34
|
+
default: if options.url.isEmpty { options.url = argument }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return options
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
|
|
41
|
+
let options: Options
|
|
42
|
+
var window: NSWindow!
|
|
43
|
+
var web: WKWebView!
|
|
44
|
+
|
|
45
|
+
init(options: Options) {
|
|
46
|
+
self.options = options
|
|
47
|
+
super.init()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
51
|
+
let configuration = WKWebViewConfiguration()
|
|
52
|
+
// The viewer is served from loopback and talks to its own session; nothing
|
|
53
|
+
// it holds should outlive the window.
|
|
54
|
+
configuration.websiteDataStore = .nonPersistent()
|
|
55
|
+
|
|
56
|
+
web = WKWebView(frame: .zero, configuration: configuration)
|
|
57
|
+
web.navigationDelegate = self
|
|
58
|
+
web.setValue(false, forKey: "drawsBackground")
|
|
59
|
+
|
|
60
|
+
window = NSWindow(
|
|
61
|
+
contentRect: NSRect(x: 0, y: 0, width: options.width, height: options.height),
|
|
62
|
+
// .resizable is what puts the green button in the corner and lets it
|
|
63
|
+
// mean fullscreen rather than zoom.
|
|
64
|
+
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
65
|
+
backing: .buffered,
|
|
66
|
+
defer: false
|
|
67
|
+
)
|
|
68
|
+
window.title = options.title
|
|
69
|
+
window.contentView = web
|
|
70
|
+
window.setFrameAutosaveName("ai-remote-viewer")
|
|
71
|
+
window.collectionBehavior.insert(.fullScreenPrimary)
|
|
72
|
+
window.center()
|
|
73
|
+
window.makeKeyAndOrderFront(nil)
|
|
74
|
+
|
|
75
|
+
buildMenu()
|
|
76
|
+
|
|
77
|
+
if let url = URL(string: options.url) {
|
|
78
|
+
web.load(URLRequest(url: url))
|
|
79
|
+
} else {
|
|
80
|
+
FileHandle.standardError.write("ai-remote-window: not a URL: \(options.url)\n".data(using: .utf8)!)
|
|
81
|
+
exit(2)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if options.fullscreen {
|
|
85
|
+
// After the first frame, or the window has no screen to fill yet.
|
|
86
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { self.window.toggleFullScreen(nil) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Enough of a menu for the shortcuts a window is expected to answer.
|
|
93
|
+
private func buildMenu() {
|
|
94
|
+
let main = NSMenu()
|
|
95
|
+
|
|
96
|
+
let appItem = NSMenuItem()
|
|
97
|
+
let appMenu = NSMenu()
|
|
98
|
+
appMenu.addItem(withTitle: "Hide \(options.title)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
|
|
99
|
+
appMenu.addItem(NSMenuItem.separator())
|
|
100
|
+
appMenu.addItem(withTitle: "Quit \(options.title)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
|
101
|
+
appItem.submenu = appMenu
|
|
102
|
+
main.addItem(appItem)
|
|
103
|
+
|
|
104
|
+
let viewItem = NSMenuItem()
|
|
105
|
+
let viewMenu = NSMenu(title: "View")
|
|
106
|
+
let full = NSMenuItem(title: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f")
|
|
107
|
+
full.keyEquivalentModifierMask = [.command, .control]
|
|
108
|
+
viewMenu.addItem(full)
|
|
109
|
+
let reload = NSMenuItem(title: "Reload", action: #selector(WKWebView.reload(_:)), keyEquivalent: "r")
|
|
110
|
+
viewMenu.addItem(reload)
|
|
111
|
+
viewItem.submenu = viewMenu
|
|
112
|
+
main.addItem(viewItem)
|
|
113
|
+
|
|
114
|
+
let editItem = NSMenuItem()
|
|
115
|
+
let editMenu = NSMenu(title: "Edit")
|
|
116
|
+
for (title, action, key) in [
|
|
117
|
+
("Cut", #selector(NSText.cut(_:)), "x"),
|
|
118
|
+
("Copy", #selector(NSText.copy(_:)), "c"),
|
|
119
|
+
("Paste", #selector(NSText.paste(_:)), "v"),
|
|
120
|
+
("Select All", #selector(NSText.selectAll(_:)), "a"),
|
|
121
|
+
] {
|
|
122
|
+
editMenu.addItem(withTitle: title, action: action, keyEquivalent: key)
|
|
123
|
+
}
|
|
124
|
+
editItem.submenu = editMenu
|
|
125
|
+
main.addItem(editItem)
|
|
126
|
+
|
|
127
|
+
NSApp.mainMenu = main
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
|
|
131
|
+
|
|
132
|
+
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
133
|
+
FileHandle.standardError.write("ai-remote-window: \(error.localizedDescription)\n".data(using: .utf8)!)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
137
|
+
FileHandle.standardError.write("ai-remote-window: \(error.localizedDescription)\n".data(using: .utf8)!)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let options = parse()
|
|
142
|
+
if options.url.isEmpty {
|
|
143
|
+
FileHandle.standardError.write("usage: ai-remote-window <url> [--title T] [--width N] [--height N] [--fullscreen]\n".data(using: .utf8)!)
|
|
144
|
+
exit(2)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let application = NSApplication.shared
|
|
148
|
+
application.setActivationPolicy(.regular)
|
|
149
|
+
let delegate = Delegate(options: options)
|
|
150
|
+
application.delegate = delegate
|
|
151
|
+
application.run()
|
|
@@ -21,17 +21,32 @@ const context = canvas.getContext('2d', { alpha: false })!;
|
|
|
21
21
|
const stateEl = $('state');
|
|
22
22
|
const sizeEl = $('size');
|
|
23
23
|
const hostEl = $('host');
|
|
24
|
-
const
|
|
25
|
-
const fitBtn = $('fit');
|
|
24
|
+
const scaleBtn = $('scale');
|
|
26
25
|
const ctlBtn = $('control');
|
|
27
26
|
const termBtn = $('term');
|
|
28
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
|
+
}
|
|
29
38
|
const termPane = $('terminal');
|
|
30
39
|
const termHost = $('termHost');
|
|
31
40
|
|
|
32
41
|
let control = false;
|
|
33
42
|
let painted = 0;
|
|
34
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
|
+
|
|
35
50
|
const socket = new WebSocket(location.href.replace(/^http/, 'ws'));
|
|
36
51
|
socket.binaryType = 'arraybuffer';
|
|
37
52
|
|
|
@@ -115,13 +130,13 @@ termBtn.addEventListener('click', () => openTerminal(termBtn.getAttribute('aria-
|
|
|
115
130
|
// --- the desktop ------------------------------------------------------------
|
|
116
131
|
|
|
117
132
|
socket.addEventListener('open', () => {
|
|
118
|
-
stateEl.
|
|
119
|
-
stateEl.
|
|
133
|
+
stateEl.className = 'dot live';
|
|
134
|
+
stateEl.title = 'connected';
|
|
120
135
|
});
|
|
121
136
|
|
|
122
137
|
socket.addEventListener('close', () => {
|
|
123
|
-
stateEl.
|
|
124
|
-
stateEl.
|
|
138
|
+
stateEl.className = 'dot dead';
|
|
139
|
+
stateEl.title = 'the session ended';
|
|
125
140
|
terminal.write('\r\n\x1b[31m[the session ended]\x1b[m\r\n');
|
|
126
141
|
});
|
|
127
142
|
|
|
@@ -160,6 +175,7 @@ function resize({ width, height }: Json): void {
|
|
|
160
175
|
canvas.width = width;
|
|
161
176
|
canvas.height = height;
|
|
162
177
|
sizeEl.textContent = `${width}×${height}`;
|
|
178
|
+
document.title = `${hostEl.textContent} — ${width}×${height}`;
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
/** Inflate with the platform's own decompressor; no library involved. */
|
|
@@ -237,14 +253,14 @@ canvas.addEventListener('keyup', (e) => {
|
|
|
237
253
|
// rather than to the browser. Fit is forced on while it lasts, because a 1:1
|
|
238
254
|
// desktop larger than the screen would otherwise be a fullscreen scrollbar.
|
|
239
255
|
|
|
240
|
-
let fitBeforeFullscreen =
|
|
256
|
+
let fitBeforeFullscreen = false;
|
|
241
257
|
|
|
242
258
|
async function toggleFullscreen(): Promise<void> {
|
|
243
259
|
if (document.fullscreenElement) {
|
|
244
260
|
await document.exitFullscreen().catch(() => {});
|
|
245
261
|
return;
|
|
246
262
|
}
|
|
247
|
-
fitBeforeFullscreen =
|
|
263
|
+
fitBeforeFullscreen = scaleBtn.getAttribute('aria-pressed') === 'true';
|
|
248
264
|
await document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
|
|
249
265
|
}
|
|
250
266
|
|
|
@@ -274,11 +290,12 @@ window.addEventListener('keydown', (event) => {
|
|
|
274
290
|
// --- controls ---------------------------------------------------------------
|
|
275
291
|
|
|
276
292
|
function setFit(on: boolean): void {
|
|
277
|
-
|
|
293
|
+
scaleBtn.setAttribute('aria-pressed', String(on));
|
|
278
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');
|
|
279
296
|
}
|
|
280
297
|
|
|
281
|
-
|
|
298
|
+
scaleBtn.addEventListener('click', () => setFit(scaleBtn.getAttribute('aria-pressed') !== 'true'));
|
|
282
299
|
|
|
283
300
|
// Ask; the server decides. The button reflects what came back, so it can never
|
|
284
301
|
// claim control the session has not actually granted.
|
|
@@ -287,14 +304,20 @@ ctlBtn.addEventListener('click', () => send({ type: 'control', on: !control }));
|
|
|
287
304
|
function setControl(on: boolean): void {
|
|
288
305
|
control = on;
|
|
289
306
|
ctlBtn.setAttribute('aria-pressed', String(on));
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
? 'You have control
|
|
293
|
-
: 'Watching.
|
|
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');
|
|
294
311
|
if (on && !termPane.classList.contains('open')) canvas.focus();
|
|
295
312
|
}
|
|
296
313
|
|
|
297
314
|
$('cad').addEventListener('click', () => {
|
|
298
|
-
if (!control) {
|
|
315
|
+
if (!control) {
|
|
316
|
+
tip('cad', 'Take control first — Ctrl+Alt+Del is input like any other');
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
299
319
|
send({ type: 'cad' });
|
|
300
320
|
});
|
|
321
|
+
|
|
322
|
+
// The desktop starts at 100%, and the window is sized to hold it.
|
|
323
|
+
setFit(false);
|
package/src/cli/viewer-page.ts
CHANGED
|
@@ -1,100 +1,136 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The viewer page.
|
|
2
|
+
* The viewer page: markup, styling, and an icon.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* strings.
|
|
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.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
import { VIEWER_CSS, VIEWER_JS } from './generated/viewer-bundle';
|
|
11
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
|
+
|
|
12
38
|
export const PAGE = `<!doctype html>
|
|
13
39
|
<html lang="en">
|
|
14
40
|
<head>
|
|
15
41
|
<meta charset="utf-8">
|
|
16
42
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
17
|
-
<title>ai-remote
|
|
43
|
+
<title>ai-remote</title>
|
|
44
|
+
<link rel="icon" href="${FAVICON}">
|
|
18
45
|
<style>
|
|
19
46
|
:root { color-scheme: dark; }
|
|
20
47
|
* { box-sizing: border-box; }
|
|
21
48
|
body {
|
|
22
49
|
margin: 0; height: 100vh; display: flex; flex-direction: column;
|
|
23
|
-
background: #
|
|
24
|
-
font:
|
|
50
|
+
background: #0f1116; color: #e6e8ee; overflow: hidden;
|
|
51
|
+
font: 12px/1.4 ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
|
|
25
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. */
|
|
26
56
|
header {
|
|
27
|
-
display: flex; align-items: center; gap:
|
|
28
|
-
padding: 8px
|
|
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;
|
|
29
60
|
}
|
|
30
|
-
|
|
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; }
|
|
31
71
|
header .spacer { flex: 1; }
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
.
|
|
37
|
-
|
|
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
|
+
|
|
38
78
|
button {
|
|
39
|
-
|
|
40
|
-
|
|
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;
|
|
41
86
|
}
|
|
42
|
-
button
|
|
43
|
-
button
|
|
44
|
-
|
|
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
|
+
|
|
45
91
|
main {
|
|
46
92
|
flex: 1; min-height: 0; display: grid; place-items: center;
|
|
47
|
-
overflow: auto;
|
|
93
|
+
overflow: auto; background: #07080b;
|
|
48
94
|
}
|
|
49
|
-
|
|
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; }
|
|
50
97
|
canvas.fit { max-width: 100%; max-height: 100%; object-fit: contain; }
|
|
98
|
+
|
|
51
99
|
#terminal {
|
|
52
|
-
display: none; min-height: 0; height:
|
|
53
|
-
border-top: 1px solid #
|
|
100
|
+
display: none; min-height: 0; height: 38%; flex: 0 0 38%;
|
|
101
|
+
border-top: 1px solid #242a34; background: #0b0d11; padding: 4px 6px 0;
|
|
54
102
|
}
|
|
55
103
|
#terminal.open { display: block; }
|
|
56
104
|
#termHost { height: 100%; }
|
|
57
|
-
footer { padding: 6px 12px; background: #181b21; border-top: 1px solid #262b34; color: #97a0b0; }
|
|
58
105
|
|
|
59
|
-
/* Fullscreen: the desktop
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
body.fullscreen { background: #000; }
|
|
63
|
-
body.fullscreen main { padding: 0; }
|
|
64
|
-
body.fullscreen footer { display: none; }
|
|
65
|
-
body.fullscreen canvas { box-shadow: none; }
|
|
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; }
|
|
66
109
|
body.fullscreen header {
|
|
67
110
|
position: fixed; inset: 0 0 auto 0; z-index: 10;
|
|
68
|
-
background: rgba(
|
|
69
|
-
transform: translateY(-100%); transition: transform .
|
|
70
|
-
}
|
|
71
|
-
body.fullscreen header:hover,
|
|
72
|
-
body.fullscreen header:focus-within { transform: none; }
|
|
73
|
-
body.fullscreen::before {
|
|
74
|
-
content: ''; position: fixed; inset: 0 0 auto 0; height: 8px; z-index: 9;
|
|
111
|
+
background: rgba(23, 26, 33, .95);
|
|
112
|
+
transform: translateY(-100%); transition: transform .16s ease;
|
|
75
113
|
}
|
|
76
|
-
body.fullscreen
|
|
114
|
+
body.fullscreen header:hover, body.fullscreen header:focus-within { transform: none; }
|
|
77
115
|
${VIEWER_CSS}
|
|
78
116
|
</style>
|
|
79
117
|
</head>
|
|
80
118
|
<body>
|
|
81
119
|
<header>
|
|
82
|
-
<span class="
|
|
83
|
-
<span class="
|
|
84
|
-
<span class="
|
|
85
|
-
<span class="pill" id="state">connecting…</span>
|
|
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>
|
|
86
123
|
<span class="spacer"></span>
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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>
|
|
92
131
|
</header>
|
|
93
|
-
<
|
|
94
|
-
|
|
95
|
-
<section id="terminal"><div id="termHost"></div></section>
|
|
96
|
-
</div>
|
|
97
|
-
<footer id="hint">Watching. The agent is driving — press “Take control” to send input yourself.</footer>
|
|
132
|
+
<main><canvas id="screen" tabindex="0"></canvas></main>
|
|
133
|
+
<section id="terminal"><div id="termHost"></div></section>
|
|
98
134
|
<script type="module">
|
|
99
135
|
${VIEWER_JS}
|
|
100
136
|
</script>
|
package/src/cli/viewer.ts
CHANGED
|
@@ -14,18 +14,22 @@
|
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
15
|
import type net from 'node:net';
|
|
16
16
|
import { randomBytes } from 'node:crypto';
|
|
17
|
-
import { spawn } from 'node:child_process';
|
|
18
17
|
import { deflateSync } from 'node:zlib';
|
|
19
18
|
import type { RdpSession } from './session';
|
|
20
19
|
import type { Shell } from './shell';
|
|
21
20
|
import { accept, type WebSocketPeer } from './wsserver';
|
|
21
|
+
import { openWindow, type WindowHandle } from './window';
|
|
22
22
|
import { PAGE } from './viewer-page';
|
|
23
23
|
|
|
24
24
|
export interface Viewer {
|
|
25
25
|
url: string;
|
|
26
|
-
/** Resolves when
|
|
26
|
+
/** Resolves when something has actually attached and asked for pixels. */
|
|
27
27
|
opened: Promise<void>;
|
|
28
|
-
/**
|
|
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. */
|
|
29
33
|
launch(): void;
|
|
30
34
|
close(): void;
|
|
31
35
|
}
|
|
@@ -49,7 +53,19 @@ function encodeRect(kind: number, left: number, top: number, width: number, heig
|
|
|
49
53
|
export async function startViewer(
|
|
50
54
|
session: RdpSession,
|
|
51
55
|
port: number,
|
|
52
|
-
{
|
|
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
|
+
} = {}
|
|
53
69
|
): Promise<Viewer> {
|
|
54
70
|
const token = randomBytes(16).toString('hex');
|
|
55
71
|
const peers = new Set<WebSocketPeer>();
|
|
@@ -220,15 +236,36 @@ export async function startViewer(
|
|
|
220
236
|
});
|
|
221
237
|
|
|
222
238
|
const url = `http://127.0.0.1:${bound}/?t=${token}`;
|
|
223
|
-
|
|
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;
|
|
224
255
|
|
|
225
256
|
return {
|
|
226
257
|
url,
|
|
227
258
|
opened,
|
|
228
|
-
|
|
259
|
+
get window() { return window; },
|
|
260
|
+
get viewers() { return peers.size; },
|
|
261
|
+
launch() { window = show(); },
|
|
229
262
|
close() {
|
|
230
263
|
for (const peer of peers) peer.close();
|
|
231
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;
|
|
232
269
|
},
|
|
233
270
|
};
|
|
234
271
|
}
|
|
@@ -257,16 +294,3 @@ function applyInput(session: RdpSession, message: any): void {
|
|
|
257
294
|
break;
|
|
258
295
|
}
|
|
259
296
|
}
|
|
260
|
-
|
|
261
|
-
/** Pop the window. A failure here is not a failure of the session. */
|
|
262
|
-
function open(url: string): void {
|
|
263
|
-
const command = process.platform === 'darwin' ? 'open'
|
|
264
|
-
: process.platform === 'win32' ? 'cmd'
|
|
265
|
-
: 'xdg-open';
|
|
266
|
-
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
267
|
-
try {
|
|
268
|
-
spawn(command, args, { stdio: 'ignore', detached: true }).unref();
|
|
269
|
-
} catch {
|
|
270
|
-
// Headless, or no browser. The URL was printed; that is enough.
|
|
271
|
-
}
|
|
272
|
-
}
|