ai-remote 0.3.0 → 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.
@@ -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()
package/src/cli/shell.ts CHANGED
@@ -9,6 +9,19 @@
9
9
  import { SshSession } from '../protocols/ssh/session';
10
10
  import { TcpTransport } from './transport';
11
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
+
12
25
  export interface ShellOptions {
13
26
  host: string;
14
27
  port: number;
@@ -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 hintEl = $('hint');
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.textContent = 'live';
119
- stateEl.className = 'pill live';
133
+ stateEl.className = 'dot live';
134
+ stateEl.title = 'connected';
120
135
  });
121
136
 
122
137
  socket.addEventListener('close', () => {
123
- stateEl.textContent = 'disconnected';
124
- stateEl.className = 'pill dead';
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 = true;
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 = fitBtn.getAttribute('aria-pressed') === 'true';
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
- fitBtn.setAttribute('aria-pressed', String(on));
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
- fitBtn.addEventListener('click', () => setFit(fitBtn.getAttribute('aria-pressed') !== 'true'));
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
- ctlBtn.textContent = on ? 'Release control' : 'Take control';
291
- hintEl.textContent = on
292
- ? 'You have control. Keyboard and pointer go to the remote machine.'
293
- : 'Watching. The agent is driving press “Take control” to send input yourself.';
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) { hintEl.textContent = 'Take control first — Ctrl+Alt+Del is input like any other.'; return; }
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);
@@ -1,100 +1,136 @@
1
1
  /**
2
- * The viewer page.
2
+ * The viewer page: markup, styling, and an icon.
3
3
  *
4
- * The markup and its styling live here; the behaviour is a real bundle, built
5
- * from src/cli/viewer-client and inlined at build time. A published CLI has no
6
- * asset directory it can rely on being unpacked beside it, so both arrive as
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 viewer</title>
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: #12141a; color: #e6e8ee;
24
- font: 13px/1.5 ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
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: 12px; flex-wrap: wrap;
28
- padding: 8px 12px; background: #181b21; border-bottom: 1px solid #262b34;
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
- header .title { font-weight: 600; }
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
- .pill {
33
- padding: 2px 8px; border-radius: 999px; background: #22272f;
34
- border: 1px solid #2f353f; font-variant-numeric: tabular-nums; font-size: 12px;
35
- }
36
- .pill.live { color: #7ee2a8; border-color: #2c5540; }
37
- .pill.dead { color: #ff9b9b; border-color: #5c3131; }
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
- font: inherit; color: inherit; background: #22272f; cursor: pointer;
40
- border: 1px solid #2f353f; border-radius: 6px; padding: 4px 10px;
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:hover { background: #2c323c; }
43
- button[aria-pressed="true"] { background: #2f4a7a; border-color: #3f63a4; }
44
- .body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
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; padding: 12px;
93
+ overflow: auto; background: #07080b;
48
94
  }
49
- canvas { display: block; background: #000; box-shadow: 0 10px 30px rgba(0,0,0,.8); outline: none; }
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: 40%;
53
- border-top: 1px solid #262b34; background: #0d0f13; padding: 6px 8px 2px;
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, and nothing else. The header stays reachable by
60
- moving the pointer to the top edge, so control and the terminal are not
61
- lost behind a keystroke nobody can guess. */
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(24, 27, 33, .94); border-bottom-color: rgba(38, 43, 52, .9);
69
- transform: translateY(-100%); transition: transform .18s ease;
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:has(header:hover)::before { height: 0; }
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="title">ai-remote</span>
83
- <span class="pill" id="host">—</span>
84
- <span class="pill" id="size">—</span>
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
- <button id="fit" aria-pressed="true">Fit</button>
88
- <button id="full" aria-pressed="false" title="Fullscreen (Ctrl+Shift+F, Escape to leave)">Fullscreen</button>
89
- <button id="control" aria-pressed="false" title="While off, you watch without sending input">Take control</button>
90
- <button id="term" aria-pressed="false">Terminal</button>
91
- <button id="cad">Ctrl+Alt+Del</button>
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
- <div class="body">
94
- <main><canvas id="screen" class="fit" tabindex="0"></canvas></main>
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 a browser has actually attached. */
26
+ /** Resolves when something has actually attached and asked for pixels. */
27
27
  opened: Promise<void>;
28
- /** Pop the window again, for a viewer that was closed by hand. */
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
- { launch = true, openShell }: { launch?: boolean; openShell?: () => Promise<Shell> } = {}
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>();
@@ -76,7 +92,10 @@ export async function startViewer(
76
92
  */
77
93
  async function attachShell(peer: WebSocketPeer): Promise<void> {
78
94
  if (!openShell) {
79
- peer.send(JSON.stringify({ type: 'shell-error', message: 'This session was started without a terminal. Restart it with --ssh-user NAME.' }));
95
+ peer.send(JSON.stringify({
96
+ type: 'shell-error',
97
+ message: 'This session has no terminal available.',
98
+ }));
80
99
  return;
81
100
  }
82
101
  if (detachShell.has(peer)) return;
@@ -217,15 +236,36 @@ export async function startViewer(
217
236
  });
218
237
 
219
238
  const url = `http://127.0.0.1:${bound}/?t=${token}`;
220
- if (launch) open(url);
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;
221
255
 
222
256
  return {
223
257
  url,
224
258
  opened,
225
- launch() { open(url); },
259
+ get window() { return window; },
260
+ get viewers() { return peers.size; },
261
+ launch() { window = show(); },
226
262
  close() {
227
263
  for (const peer of peers) peer.close();
228
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;
229
269
  },
230
270
  };
231
271
  }
@@ -254,16 +294,3 @@ function applyInput(session: RdpSession, message: any): void {
254
294
  break;
255
295
  }
256
296
  }
257
-
258
- /** Pop the window. A failure here is not a failure of the session. */
259
- function open(url: string): void {
260
- const command = process.platform === 'darwin' ? 'open'
261
- : process.platform === 'win32' ? 'cmd'
262
- : 'xdg-open';
263
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
264
- try {
265
- spawn(command, args, { stdio: 'ignore', detached: true }).unref();
266
- } catch {
267
- // Headless, or no browser. The URL was printed; that is enough.
268
- }
269
- }