glimpseui 0.1.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/README.md ADDED
@@ -0,0 +1,352 @@
1
+ # Glimpse
2
+
3
+ Native macOS micro-UI for scripts and agents.
4
+
5
+ Glimpse opens a native WKWebView window in under 50ms and speaks a bidirectional JSON Lines protocol over stdin/stdout. No Electron, no browser, no runtime dependencies — just a tiny Swift binary and a Node.js wrapper.
6
+
7
+ ## Requirements
8
+
9
+ - macOS (any recent version)
10
+ - Xcode Command Line Tools: `xcode-select --install`
11
+ - Node.js 18+
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install glimpse
17
+ ```
18
+
19
+ `npm install` automatically compiles the Swift binary via a `postinstall` hook (~2 seconds). See [Compile on Install](#compile-on-install) for details.
20
+
21
+ **Manual build:**
22
+ ```bash
23
+ npm run build
24
+ # or directly:
25
+ swiftc src/glimpse.swift -o src/glimpse
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ```js
31
+ import { open } from 'glimpse';
32
+
33
+ const win = open(`
34
+ <html>
35
+ <body style="font-family:sans-serif; padding:2rem;">
36
+ <h2>Hello from Glimpse</h2>
37
+ <button onclick="glimpse.send({ action: 'greet' })">Say hello</button>
38
+ </body>
39
+ </html>
40
+ `, { width: 400, height: 300, title: 'My App' });
41
+
42
+ win.on('message', (data) => {
43
+ console.log('Received:', data); // { action: 'greet' }
44
+ win.close();
45
+ });
46
+
47
+ win.on('closed', () => process.exit(0));
48
+ ```
49
+
50
+ ## Window Modes
51
+
52
+ Glimpse supports several window style flags that can be combined freely:
53
+
54
+ | Flag | Effect |
55
+ |------|--------|
56
+ | `frameless` | Removes the title bar — use your own HTML chrome |
57
+ | `floating` | Always on top of other windows |
58
+ | `transparent` | Clear window background — HTML body needs `background: transparent` |
59
+ | `clickThrough` | Window ignores all mouse events |
60
+
61
+ Common combinations:
62
+
63
+ - **Floating HUD**: `floating: true` — status panels, agent indicators
64
+ - **Custom dialog**: `frameless: true` — clean UI with no system chrome
65
+ - **Overlay**: `frameless + transparent` — shaped widgets that float over content
66
+ - **Companion widget**: `frameless + transparent + floating + clickThrough` — visual-only overlays that don't interfere with the desktop
67
+
68
+ ## Follow Cursor
69
+
70
+ Attach a window to the cursor. Combined with `transparent + frameless + floating + clickThrough`, this creates visual companions that follow the mouse without interfering with normal usage.
71
+
72
+ ```js
73
+ import { open } from './src/glimpse.mjs';
74
+
75
+ const win = open(`
76
+ <body style="background: transparent; margin: 0;">
77
+ <svg width="60" height="60" style="filter: drop-shadow(0 0 8px rgba(0,255,200,0.6));">
78
+ <circle cx="30" cy="30" r="20" fill="none" stroke="cyan" stroke-width="2">
79
+ <animateTransform attributeName="transform" type="rotate"
80
+ from="0 30 30" to="360 30 30" dur="1s" repeatCount="indefinite"/>
81
+ </circle>
82
+ </svg>
83
+ </body>
84
+ `, {
85
+ width: 60, height: 60,
86
+ transparent: true,
87
+ frameless: true,
88
+ followCursor: true,
89
+ clickThrough: true,
90
+ cursorOffset: { x: 20, y: -20 }
91
+ });
92
+ ```
93
+
94
+ The window tracks the cursor in real-time across all screens. `followCursor` implies `floating` — the window stays on top automatically.
95
+
96
+ You can also toggle tracking dynamically after the window is open:
97
+
98
+ ```js
99
+ win.followCursor(false); // stop tracking
100
+ win.followCursor(true); // resume tracking
101
+ ```
102
+
103
+ **Use cases:** animated SVG companions, agent "thinking" indicators, floating tooltips, custom cursor replacements.
104
+
105
+ ## API Reference
106
+
107
+ ### `open(html, options?)`
108
+
109
+ Opens a native window and returns a `GlimpseWindow`. The HTML is displayed once the WebView signals ready.
110
+
111
+ ```js
112
+ import { open } from 'glimpse';
113
+
114
+ const win = open('<html>...</html>', {
115
+ width: 800, // default: 800
116
+ height: 600, // default: 600
117
+ title: 'App', // default: "Glimpse"
118
+ });
119
+ ```
120
+
121
+ **All options:**
122
+
123
+ | Option | Type | Default | Description |
124
+ |--------|------|---------|-------------|
125
+ | `width` | number | `800` | Window width in pixels |
126
+ | `height` | number | `600` | Window height in pixels |
127
+ | `title` | string | `"Glimpse"` | Title bar text (ignored when frameless) |
128
+ | `x` | number | — | Horizontal screen position (omit to center) |
129
+ | `y` | number | — | Vertical screen position (omit to center) |
130
+ | `frameless` | boolean | `false` | Remove the title bar |
131
+ | `floating` | boolean | `false` | Always on top of other windows |
132
+ | `transparent` | boolean | `false` | Transparent window background |
133
+ | `clickThrough` | boolean | `false` | Window ignores all mouse events |
134
+ | `followCursor` | boolean | `false` | Track cursor position in real-time |
135
+ | `cursorOffset` | `{ x?, y? }` | `{ x: 20, y: -20 }` | Pixel offset from cursor when `followCursor` is on |
136
+ | `autoClose` | boolean | `false` | Close the window automatically after the first `message` event |
137
+
138
+ ### `prompt(html, options?)`
139
+
140
+ One-shot helper — opens a window, waits for the first message, then closes it automatically. Returns a `Promise<data | null>` where `data` is the first message payload and `null` means the user closed the window without sending anything.
141
+
142
+ ```js
143
+ import { prompt } from 'glimpse';
144
+
145
+ const answer = await prompt(`
146
+ <h2>Delete this file?</h2>
147
+ <button onclick="window.glimpse.send({ok: true})">Yes</button>
148
+ <button onclick="window.glimpse.send({ok: false})">No</button>
149
+ `, { width: 300, height: 150, title: 'Confirm' });
150
+
151
+ if (answer?.ok) console.log('Deleted!');
152
+ ```
153
+
154
+ Accepts the same `options` as `open()`. Optional `options.timeout` (ms) rejects the promise if no message arrives in time.
155
+
156
+ ### GlimpseWindow
157
+
158
+ `GlimpseWindow` extends `EventEmitter`.
159
+
160
+ #### Events
161
+
162
+ | Event | Payload | Description |
163
+ |-------|---------|-------------|
164
+ | `ready` | — | WebView is loaded and ready to receive commands |
165
+ | `message` | `data: object` | Message sent from the page via `window.glimpse.send(data)` |
166
+ | `closed` | — | Window was closed (by user or via `.close()`) |
167
+ | `error` | `Error` | Process error or malformed protocol line |
168
+
169
+ ```js
170
+ win.on('ready', () => console.log('window ready'));
171
+ win.on('message', (msg) => console.log('from page:', msg));
172
+ win.on('closed', () => process.exit(0));
173
+ win.on('error', (err) => console.error(err));
174
+ ```
175
+
176
+ #### Methods
177
+
178
+ **`win.send(js)`** — Evaluate JavaScript in the WebView.
179
+ ```js
180
+ win.send(`document.body.style.background = 'coral'`);
181
+ win.send(`document.getElementById('status').textContent = 'Done'`);
182
+ ```
183
+
184
+ **`win.setHTML(html)`** — Replace the entire page content.
185
+ ```js
186
+ win.setHTML('<html><body><h1>Step 2</h1></body></html>');
187
+ ```
188
+
189
+ **`win.followCursor(enabled)`** — Start or stop cursor tracking at runtime.
190
+ ```js
191
+ win.followCursor(true); // attach to cursor
192
+ win.followCursor(false); // detach
193
+ ```
194
+
195
+ **`win.loadFile(path)`** — Load a local HTML file into the WebView by absolute path.
196
+ ```js
197
+ win.loadFile('/path/to/page.html');
198
+ ```
199
+
200
+ **`win.close()`** — Close the window programmatically.
201
+ ```js
202
+ win.close();
203
+ ```
204
+
205
+ ### JavaScript Bridge (in-page)
206
+
207
+ Every page loaded by Glimpse gets a `window.glimpse` object injected at document start:
208
+
209
+ ```js
210
+ // Send any JSON-serializable value to Node.js → triggers 'message' event
211
+ window.glimpse.send({ action: 'submit', value: 42 });
212
+
213
+ // Close the window from inside the page
214
+ window.glimpse.close();
215
+ ```
216
+
217
+ ## Protocol
218
+
219
+ Glimpse uses a newline-delimited JSON (JSON Lines) protocol. Each line is a complete JSON object. This makes it easy to drive the binary from any language.
220
+
221
+ ### Stdin → Glimpse (commands)
222
+
223
+ **Set HTML** — Replace page content. HTML must be base64-encoded.
224
+ ```json
225
+ {"type":"html","html":"<base64-encoded HTML>"}
226
+ ```
227
+
228
+ **Eval JavaScript** — Run JS in the WebView.
229
+ ```json
230
+ {"type":"eval","js":"document.title = 'Updated'"}
231
+ ```
232
+
233
+ **Follow Cursor** — Toggle cursor tracking at runtime.
234
+ ```json
235
+ {"type":"follow-cursor","enabled":true}
236
+ {"type":"follow-cursor","enabled":false}
237
+ ```
238
+
239
+ **Load File** — Load a local HTML file by absolute path.
240
+ ```json
241
+ {"type":"file","path":"/path/to/page.html"}
242
+ ```
243
+
244
+ **Close** — Close the window and exit.
245
+ ```json
246
+ {"type":"close"}
247
+ ```
248
+
249
+ ### Stdout → Host (events)
250
+
251
+ **Ready** — WebView finished loading initial blank page. Send HTML after this.
252
+ ```json
253
+ {"type":"ready"}
254
+ ```
255
+
256
+ **Message** — Data sent from the page via `window.glimpse.send(...)`.
257
+ ```json
258
+ {"type":"message","data":{"action":"submit","value":42}}
259
+ ```
260
+
261
+ **Closed** — Window closed (by user or via close command).
262
+ ```json
263
+ {"type":"closed"}
264
+ ```
265
+
266
+ Diagnostic logs are written to **stderr** (prefixed `[glimpse]`) and do not affect the protocol.
267
+
268
+ ## CLI Usage
269
+
270
+ Drive the binary directly from any language — shell, Python, Ruby, etc.
271
+
272
+ ```bash
273
+ # Basic usage
274
+ echo '{"type":"html","html":"PGh0bWw+PGJvZHk+SGVsbG8hPC9ib2R5PjwvaHRtbD4="}' \
275
+ | ./src/glimpse --width 400 --height 300 --title "Hello"
276
+ ```
277
+
278
+ Available flags:
279
+
280
+ | Flag | Default | Description |
281
+ |------|---------|-------------|
282
+ | `--width N` | `800` | Window width in pixels |
283
+ | `--height N` | `600` | Window height in pixels |
284
+ | `--title STR` | `"Glimpse"` | Window title bar text |
285
+ | `--x N` | — | Horizontal screen position (omit to center) |
286
+ | `--y N` | — | Vertical screen position (omit to center) |
287
+ | `--frameless` | off | Remove the title bar |
288
+ | `--floating` | off | Always on top of other windows |
289
+ | `--transparent` | off | Transparent window background |
290
+ | `--click-through` | off | Window ignores all mouse events |
291
+ | `--follow-cursor` | off | Track cursor position in real-time |
292
+ | `--cursor-offset-x N` | `20` | Horizontal offset from cursor |
293
+ | `--cursor-offset-y N` | `-20` | Vertical offset from cursor |
294
+ | `--auto-close` | off | Exit after receiving the first message from the page |
295
+
296
+ **Shell example — encode HTML and pipe it in:**
297
+ ```bash
298
+ HTML=$(echo '<html><body><h1>Hi</h1></body></html>' | base64)
299
+ {
300
+ echo "{\"type\":\"html\",\"html\":\"$HTML\"}"
301
+ cat # keep stdin open so the window stays up
302
+ } | ./src/glimpse --width 600 --height 400
303
+ ```
304
+
305
+ **Python example:**
306
+ ```python
307
+ import subprocess, base64, json
308
+
309
+ html = b"<html><body><h1>Hello from Python</h1></body></html>"
310
+ proc = subprocess.Popen(
311
+ ["./src/glimpse", "--width", "500", "--height", "400"],
312
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE
313
+ )
314
+
315
+ cmd = json.dumps({"type": "html", "html": base64.b64encode(html).decode()})
316
+ proc.stdin.write((cmd + "\n").encode())
317
+ proc.stdin.flush()
318
+
319
+ for line in proc.stdout:
320
+ msg = json.loads(line)
321
+ if msg["type"] == "ready":
322
+ print("Window is ready")
323
+ elif msg["type"] == "message":
324
+ print("From page:", msg["data"])
325
+ elif msg["type"] == "closed":
326
+ break
327
+ ```
328
+
329
+ ## Compile on Install
330
+
331
+ Every Mac ships with `swiftc` once Xcode Command Line Tools are installed — no Xcode IDE required. Glimpse takes advantage of this: running `npm install` triggers a `postinstall` script that compiles `src/glimpse.swift` into a native binary in about 2 seconds.
332
+
333
+ ```
334
+ > glimpse@0.1.0 postinstall
335
+ > npm run build
336
+
337
+ swiftc src/glimpse.swift -o src/glimpse ✓
338
+ ```
339
+
340
+ **If compilation fails**, the most common cause is missing Xcode CLT:
341
+ ```bash
342
+ xcode-select --install
343
+ ```
344
+
345
+ To recompile manually at any time:
346
+ ```bash
347
+ npm run build
348
+ ```
349
+
350
+ ## License
351
+
352
+ MIT
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { open } from '../src/glimpse.mjs';
4
+ import { readFileSync, existsSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+
7
+ const args = process.argv.slice(2);
8
+
9
+ // Parse flags and collect positional args
10
+ const flags = {};
11
+ const positional = [];
12
+ for (let i = 0; i < args.length; i++) {
13
+ const arg = args[i];
14
+ if (arg === '--help' || arg === '-h') { flags.help = true; }
15
+ else if (arg === '--demo') { flags.demo = true; }
16
+ else if (arg === '--frameless') { flags.frameless = true; }
17
+ else if (arg === '--floating') { flags.floating = true; }
18
+ else if (arg === '--transparent') { flags.transparent = true; }
19
+ else if (arg === '--click-through') { flags.clickThrough = true; }
20
+ else if (arg === '--follow-cursor') { flags.followCursor = true; }
21
+ else if (arg === '--auto-close') { flags.autoClose = true; }
22
+ else if (arg === '--width' && args[i + 1]) { flags.width = parseInt(args[++i]); }
23
+ else if (arg === '--height' && args[i + 1]) { flags.height = parseInt(args[++i]); }
24
+ else if (arg === '--title' && args[i + 1]) { flags.title = args[++i]; }
25
+ else if (arg === '--x' && args[i + 1]) { flags.x = parseInt(args[++i]); }
26
+ else if (arg === '--y' && args[i + 1]) { flags.y = parseInt(args[++i]); }
27
+ else if (arg === '--cursor-offset-x' && args[i + 1]) { flags.cursorOffset = { ...flags.cursorOffset, x: parseInt(args[++i]) }; }
28
+ else if (arg === '--cursor-offset-y' && args[i + 1]) { flags.cursorOffset = { ...flags.cursorOffset, y: parseInt(args[++i]) }; }
29
+ else if (!arg.startsWith('-')) { positional.push(arg); }
30
+ else { console.error(`Unknown flag: ${arg}`); process.exit(1); }
31
+ }
32
+
33
+ if (flags.help) {
34
+ console.log(`
35
+ glimpseui — Native macOS micro-UI for scripts and agents
36
+
37
+ Usage:
38
+ glimpseui [options] [file.html] Open an HTML file
39
+ echo '<h1>Hi</h1>' | glimpseui Pipe HTML from stdin
40
+ glimpseui --demo Show a demo window
41
+
42
+ Options:
43
+ --width <n> Window width (default: 800)
44
+ --height <n> Window height (default: 600)
45
+ --title <text> Window title (default: "Glimpse")
46
+ --frameless No title bar
47
+ --floating Always on top
48
+ --transparent Transparent background
49
+ --click-through Mouse passes through
50
+ --follow-cursor Window follows cursor
51
+ --cursor-offset-x <n> Cursor X offset (default: 20)
52
+ --cursor-offset-y <n> Cursor Y offset (default: -20)
53
+ --auto-close Close after first window.glimpse.send()
54
+ --x <n> Window X position
55
+ --y <n> Window Y position
56
+ --demo Show a demo window
57
+ --help, -h Show this help
58
+ `);
59
+ process.exit(0);
60
+ }
61
+
62
+ const DEMO_HTML = `
63
+ <body style="margin: 0; font-family: system-ui; background: transparent !important;">
64
+ <style>
65
+ .container {
66
+ padding: 32px; height: 100vh; box-sizing: border-box;
67
+ background: rgba(20, 20, 35, 0.9); backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px);
68
+ border-radius: 16px; border: 1px solid rgba(255,255,255,0.1);
69
+ display: flex; flex-direction: column; gap: 16px;
70
+ }
71
+ h1 { margin: 0; font-size: 22px; color: white; }
72
+ h1 span { background: linear-gradient(135deg, #e94560, #4299e1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
73
+ p { margin: 0; color: #888; font-size: 14px; line-height: 1.5; }
74
+ code { background: rgba(255,255,255,0.1); padding: 2px 6px; border-radius: 4px; font-size: 13px; color: #ccc; }
75
+ .buttons { display: flex; gap: 10px; margin-top: auto; }
76
+ button {
77
+ flex: 1; padding: 12px; font-size: 14px; font-weight: 600; border: none;
78
+ border-radius: 10px; cursor: pointer; transition: all 0.15s;
79
+ }
80
+ button:hover { transform: scale(1.03); }
81
+ button:active { transform: scale(0.97); }
82
+ .primary { background: linear-gradient(135deg, #e94560, #c23152); color: white; }
83
+ .primary:hover { box-shadow: 0 0 20px rgba(233,69,96,0.4); }
84
+ .secondary { background: rgba(255,255,255,0.1); color: #ccc; }
85
+ .secondary:hover { background: rgba(255,255,255,0.15); }
86
+ .features { display: flex; flex-wrap: wrap; gap: 6px; }
87
+ .tag { background: rgba(66,153,225,0.15); color: #4299e1; padding: 4px 10px; border-radius: 6px; font-size: 12px; }
88
+ </style>
89
+ <div class="container">
90
+ <h1>👁️ <span>Glimpse</span></h1>
91
+ <p>Native macOS micro-UI. Sub-50ms windows with WKWebView.<br>
92
+ Bidirectional communication via <code>window.glimpse.send()</code></p>
93
+ <div class="features">
94
+ <span class="tag">Frameless</span>
95
+ <span class="tag">Transparent</span>
96
+ <span class="tag">Floating</span>
97
+ <span class="tag">Follow Cursor</span>
98
+ <span class="tag">Keyboard</span>
99
+ <span class="tag">Auto-close</span>
100
+ </div>
101
+ <div class="buttons">
102
+ <button class="secondary" onclick="window.glimpse.send({action:'docs'})">📖 Docs</button>
103
+ <button class="primary" onclick="window.glimpse.send({action:'cool'})">🚀 Cool!</button>
104
+ </div>
105
+ </div>
106
+ <script>
107
+ document.addEventListener('keydown', e => {
108
+ if (e.key === 'Escape' || e.key === 'Enter') window.glimpse.send({action:'close'});
109
+ });
110
+ </script>
111
+ </body>`;
112
+
113
+ async function main() {
114
+ let html;
115
+
116
+ if (flags.demo) {
117
+ html = DEMO_HTML;
118
+ flags.frameless = flags.frameless ?? true;
119
+ flags.transparent = flags.transparent ?? true;
120
+ flags.width = flags.width ?? 380;
121
+ flags.height = flags.height ?? 320;
122
+ } else if (positional.length > 0) {
123
+ // Load from file
124
+ const file = resolve(positional[0]);
125
+ if (!existsSync(file)) {
126
+ console.error(`File not found: ${file}`);
127
+ process.exit(1);
128
+ }
129
+ html = readFileSync(file, 'utf-8');
130
+ } else if (!process.stdin.isTTY) {
131
+ // Read from stdin
132
+ const chunks = [];
133
+ for await (const chunk of process.stdin) {
134
+ chunks.push(chunk);
135
+ }
136
+ html = Buffer.concat(chunks).toString('utf-8');
137
+ } else {
138
+ console.error('Usage: glimpseui [options] [file.html]');
139
+ console.error(' echo "<h1>Hi</h1>" | glimpseui');
140
+ console.error(' glimpseui --demo');
141
+ console.error('');
142
+ console.error('Run glimpseui --help for all options.');
143
+ process.exit(1);
144
+ }
145
+
146
+ const win = open(html, flags);
147
+
148
+ win.on('message', data => {
149
+ console.log(JSON.stringify(data));
150
+ });
151
+
152
+ win.on('closed', () => {
153
+ process.exit(0);
154
+ });
155
+ }
156
+
157
+ main().catch(err => {
158
+ console.error(err.message);
159
+ process.exit(1);
160
+ });
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "glimpseui",
3
+ "version": "0.1.0",
4
+ "description": "Native macOS micro-UI for scripts and agents — sub-50ms WKWebView windows with bidirectional JSON communication",
5
+ "type": "module",
6
+ "main": "src/glimpse.mjs",
7
+ "exports": {
8
+ ".": "./src/glimpse.mjs"
9
+ },
10
+ "bin": {
11
+ "glimpseui": "bin/glimpse.mjs"
12
+ },
13
+ "scripts": {
14
+ "build": "swiftc -O src/glimpse.swift -o src/glimpse",
15
+ "postinstall": "npm run build",
16
+ "test": "node test.mjs",
17
+ "publish:check": "./scripts/publish.sh --dry-run",
18
+ "publish:npm": "./scripts/publish.sh"
19
+ },
20
+ "files": [
21
+ "src/glimpse.swift",
22
+ "src/glimpse.mjs",
23
+ "bin/glimpse.mjs",
24
+ "README.md"
25
+ ],
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "os": [
30
+ "darwin"
31
+ ],
32
+ "keywords": [
33
+ "macos",
34
+ "webview",
35
+ "native",
36
+ "ui",
37
+ "webkit",
38
+ "wkwebview",
39
+ "swift",
40
+ "gui",
41
+ "agent",
42
+ "dialog",
43
+ "prompt",
44
+ "overlay"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/hjanuschka/glimpse"
49
+ },
50
+ "author": "hjanuschka",
51
+ "license": "MIT"
52
+ }
@@ -0,0 +1,159 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { spawn } from 'node:child_process';
3
+ import { createInterface } from 'node:readline';
4
+ import { existsSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const BINARY = join(__dirname, 'glimpse');
10
+
11
+ class GlimpseWindow extends EventEmitter {
12
+ #proc;
13
+ #closed = false;
14
+ #pendingHTML = null;
15
+
16
+ constructor(proc, initialHTML) {
17
+ super();
18
+ this.#proc = proc;
19
+ this.#pendingHTML = initialHTML;
20
+
21
+ proc.stdin.on('error', () => {}); // Swallow EPIPE if Swift exits first
22
+
23
+ const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
24
+
25
+ rl.on('line', (line) => {
26
+ let msg;
27
+ try {
28
+ msg = JSON.parse(line);
29
+ } catch {
30
+ this.emit('error', new Error(`Malformed protocol line: ${line}`));
31
+ return;
32
+ }
33
+
34
+ switch (msg.type) {
35
+ case 'ready':
36
+ if (this.#pendingHTML) {
37
+ // First ready = blank page loaded. Send the queued HTML.
38
+ this.setHTML(this.#pendingHTML);
39
+ this.#pendingHTML = null;
40
+ } else {
41
+ // Subsequent ready = user HTML loaded. Notify caller.
42
+ this.emit('ready');
43
+ }
44
+ break;
45
+ case 'message':
46
+ this.emit('message', msg.data);
47
+ break;
48
+ case 'closed':
49
+ if (!this.#closed) {
50
+ this.#closed = true;
51
+ this.emit('closed');
52
+ }
53
+ break;
54
+ default:
55
+ break;
56
+ }
57
+ });
58
+
59
+ proc.on('error', (err) => this.emit('error', err));
60
+
61
+ proc.on('exit', () => {
62
+ if (!this.#closed) {
63
+ this.#closed = true;
64
+ this.emit('closed');
65
+ }
66
+ });
67
+ }
68
+
69
+ #write(obj) {
70
+ if (this.#closed) return;
71
+ this.#proc.stdin.write(JSON.stringify(obj) + '\n');
72
+ }
73
+
74
+ send(js) {
75
+ this.#write({ type: 'eval', js });
76
+ }
77
+
78
+ setHTML(html) {
79
+ this.#write({ type: 'html', html: Buffer.from(html).toString('base64') });
80
+ }
81
+
82
+ close() {
83
+ this.#write({ type: 'close' });
84
+ }
85
+
86
+ loadFile(path) {
87
+ this.#write({ type: 'file', path });
88
+ }
89
+
90
+ followCursor(enabled) {
91
+ this.#write({ type: 'follow-cursor', enabled });
92
+ }
93
+ }
94
+
95
+ export function open(html, options = {}) {
96
+ if (!existsSync(BINARY)) {
97
+ throw new Error(
98
+ "Glimpse binary not found. Run 'npm run build' or 'swiftc src/glimpse.swift -o src/glimpse'"
99
+ );
100
+ }
101
+
102
+ const args = [];
103
+ if (options.width != null) args.push('--width', String(options.width));
104
+ if (options.height != null) args.push('--height', String(options.height));
105
+ if (options.title != null) args.push('--title', options.title);
106
+
107
+ if (options.frameless) args.push('--frameless');
108
+ if (options.floating) args.push('--floating');
109
+ if (options.transparent) args.push('--transparent');
110
+ if (options.clickThrough) args.push('--click-through');
111
+ if (options.followCursor) args.push('--follow-cursor');
112
+ if (options.autoClose) args.push('--auto-close');
113
+
114
+ if (options.x != null) args.push('--x', String(options.x));
115
+ if (options.y != null) args.push('--y', String(options.y));
116
+
117
+ if (options.cursorOffset?.x != null) args.push('--cursor-offset-x', String(options.cursorOffset.x));
118
+ if (options.cursorOffset?.y != null) args.push('--cursor-offset-y', String(options.cursorOffset.y));
119
+
120
+ const proc = spawn(BINARY, args, { stdio: ['pipe', 'pipe', 'inherit'] });
121
+ return new GlimpseWindow(proc, html);
122
+ }
123
+
124
+ export function prompt(html, options = {}) {
125
+ return new Promise((resolve, reject) => {
126
+ const win = open(html, { ...options, autoClose: true });
127
+ let resolved = false;
128
+
129
+ const timer = options.timeout
130
+ ? setTimeout(() => {
131
+ if (!resolved) { resolved = true; win.close(); reject(new Error('Prompt timed out')); }
132
+ }, options.timeout)
133
+ : null;
134
+
135
+ win.once('message', (data) => {
136
+ if (!resolved) {
137
+ resolved = true;
138
+ if (timer) clearTimeout(timer);
139
+ resolve(data);
140
+ }
141
+ });
142
+
143
+ win.once('closed', () => {
144
+ if (timer) clearTimeout(timer);
145
+ if (!resolved) {
146
+ resolved = true;
147
+ resolve(null); // User closed window without sending a message
148
+ }
149
+ });
150
+
151
+ win.once('error', (err) => {
152
+ if (timer) clearTimeout(timer);
153
+ if (!resolved) {
154
+ resolved = true;
155
+ reject(err);
156
+ }
157
+ });
158
+ });
159
+ }
@@ -0,0 +1,355 @@
1
+ import Cocoa
2
+ import WebKit
3
+ import Foundation
4
+
5
+ // MARK: - Stdout Helper
6
+
7
+ func writeToStdout(_ dict: [String: Any]) {
8
+ guard let data = try? JSONSerialization.data(withJSONObject: dict),
9
+ let line = String(data: data, encoding: .utf8) else { return }
10
+ let output = line + "\n"
11
+ FileHandle.standardOutput.write(output.data(using: .utf8)!)
12
+ fflush(stdout)
13
+ }
14
+
15
+ func log(_ message: String) {
16
+ fputs("[glimpse] \(message)\n", stderr)
17
+ }
18
+
19
+ // MARK: - CLI Config
20
+
21
+ struct Config {
22
+ var width: Int = 800
23
+ var height: Int = 600
24
+ var title: String = "Glimpse"
25
+ var frameless: Bool = false
26
+ var floating: Bool = false
27
+ var transparent: Bool = false
28
+ var x: Int? = nil
29
+ var y: Int? = nil
30
+ var followCursor: Bool = false
31
+ var cursorOffsetX: Int = 20
32
+ var cursorOffsetY: Int = -20
33
+ var clickThrough: Bool = false
34
+ var autoClose: Bool = false
35
+ }
36
+
37
+ func parseArgs() -> Config {
38
+ var config = Config()
39
+ let args = CommandLine.arguments
40
+ var i = 1
41
+ while i < args.count {
42
+ switch args[i] {
43
+ case "--width":
44
+ i += 1
45
+ if i < args.count, let v = Int(args[i]) { config.width = v }
46
+ case "--height":
47
+ i += 1
48
+ if i < args.count, let v = Int(args[i]) { config.height = v }
49
+ case "--title":
50
+ i += 1
51
+ if i < args.count { config.title = args[i] }
52
+ case "--frameless":
53
+ config.frameless = true
54
+ case "--floating":
55
+ config.floating = true
56
+ case "--transparent":
57
+ config.transparent = true
58
+ case "--x":
59
+ i += 1
60
+ if i < args.count, let v = Int(args[i]) { config.x = v }
61
+ case "--y":
62
+ i += 1
63
+ if i < args.count, let v = Int(args[i]) { config.y = v }
64
+ case "--follow-cursor":
65
+ config.followCursor = true
66
+ case "--cursor-offset-x":
67
+ i += 1
68
+ if i < args.count, let v = Int(args[i]) { config.cursorOffsetX = v }
69
+ case "--cursor-offset-y":
70
+ i += 1
71
+ if i < args.count, let v = Int(args[i]) { config.cursorOffsetY = v }
72
+ case "--click-through":
73
+ config.clickThrough = true
74
+ case "--auto-close":
75
+ config.autoClose = true
76
+ default:
77
+ break
78
+ }
79
+ i += 1
80
+ }
81
+ return config
82
+ }
83
+
84
+ // MARK: - Window Subclass (keyboard support for frameless windows)
85
+
86
+ class GlimpsePanel: NSWindow {
87
+ override var canBecomeKey: Bool { true }
88
+ override var canBecomeMain: Bool { true }
89
+ }
90
+
91
+ // MARK: - AppDelegate
92
+
93
+ @MainActor
94
+ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScriptMessageHandler, NSWindowDelegate {
95
+
96
+ var window: NSWindow!
97
+ var webView: WKWebView!
98
+ let config: Config
99
+
100
+ // Mouse monitor references for follow-cursor mode
101
+ var globalMouseMonitor: Any?
102
+ var localMouseMonitor: Any?
103
+
104
+ nonisolated init(config: Config) {
105
+ self.config = config
106
+ }
107
+
108
+ func applicationDidFinishLaunching(_ notification: Notification) {
109
+ setupWindow()
110
+ setupWebView()
111
+ if config.followCursor {
112
+ startFollowingCursor()
113
+ }
114
+ startStdinReader()
115
+ }
116
+
117
+ // MARK: - Setup
118
+
119
+ private func setupWindow() {
120
+ let rect = NSRect(x: 0, y: 0, width: config.width, height: config.height)
121
+ let styleMask: NSWindow.StyleMask = config.frameless
122
+ ? [.borderless]
123
+ : [.titled, .closable, .miniaturizable, .resizable]
124
+ window = GlimpsePanel(
125
+ contentRect: rect,
126
+ styleMask: styleMask,
127
+ backing: .buffered,
128
+ defer: false
129
+ )
130
+ window.title = config.title
131
+ if config.frameless {
132
+ window.isMovableByWindowBackground = true
133
+ }
134
+ if config.floating || config.followCursor {
135
+ window.level = .floating
136
+ }
137
+ if config.clickThrough {
138
+ window.ignoresMouseEvents = true
139
+ }
140
+ if config.transparent {
141
+ window.isOpaque = false
142
+ window.backgroundColor = .clear
143
+ }
144
+ if config.followCursor {
145
+ let mouse = NSEvent.mouseLocation
146
+ let x = mouse.x + CGFloat(config.cursorOffsetX)
147
+ let y = mouse.y + CGFloat(config.cursorOffsetY)
148
+ window.setFrameOrigin(NSPoint(x: x, y: y))
149
+ } else if let x = config.x, let y = config.y {
150
+ window.setFrameOrigin(NSPoint(x: x, y: y))
151
+ } else {
152
+ window.center()
153
+ }
154
+ window.delegate = self
155
+ window.makeKeyAndOrderFront(nil)
156
+ NSApp.activate(ignoringOtherApps: true)
157
+ }
158
+
159
+ private func setupWebView() {
160
+ let ucc = WKUserContentController()
161
+
162
+ let bridgeJS = """
163
+ window.glimpse = {
164
+ send: function(data) {
165
+ window.webkit.messageHandlers.glimpse.postMessage(JSON.stringify(data));
166
+ },
167
+ close: function() {
168
+ window.webkit.messageHandlers.glimpse.postMessage(JSON.stringify({__glimpse_close: true}));
169
+ }
170
+ };
171
+ """
172
+ let script = WKUserScript(source: bridgeJS, injectionTime: .atDocumentStart, forMainFrameOnly: true)
173
+ ucc.addUserScript(script)
174
+ ucc.add(self, name: "glimpse")
175
+
176
+ let wkConfig = WKWebViewConfiguration()
177
+ wkConfig.userContentController = ucc
178
+
179
+ webView = WKWebView(frame: window.contentView!.bounds, configuration: wkConfig)
180
+ webView.autoresizingMask = [.width, .height]
181
+ webView.navigationDelegate = self
182
+ if config.transparent {
183
+ webView.underPageBackgroundColor = .clear
184
+ webView.setValue(false, forKey: "drawsBackground")
185
+ }
186
+ window.contentView?.addSubview(webView)
187
+
188
+ // Load blank page so didFinish fires and we emit "ready"
189
+ webView.loadHTMLString("<html><body></body></html>", baseURL: nil)
190
+ }
191
+
192
+ // MARK: - Follow Cursor
193
+
194
+ func startFollowingCursor() {
195
+ guard globalMouseMonitor == nil else { return }
196
+ window.level = .floating
197
+ let moveHandler: (NSEvent) -> Void = { [weak self] _ in
198
+ guard let self else { return }
199
+ let mouse = NSEvent.mouseLocation
200
+ let x = mouse.x + CGFloat(self.config.cursorOffsetX)
201
+ let y = mouse.y + CGFloat(self.config.cursorOffsetY)
202
+ self.window.setFrameOrigin(NSPoint(x: x, y: y))
203
+ }
204
+ globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(
205
+ matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged],
206
+ handler: moveHandler
207
+ )
208
+ localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged]) { [weak self] event in
209
+ guard let self else { return event }
210
+ let mouse = NSEvent.mouseLocation
211
+ let x = mouse.x + CGFloat(self.config.cursorOffsetX)
212
+ let y = mouse.y + CGFloat(self.config.cursorOffsetY)
213
+ self.window.setFrameOrigin(NSPoint(x: x, y: y))
214
+ return event
215
+ }
216
+ }
217
+
218
+ func stopFollowingCursor() {
219
+ if let monitor = globalMouseMonitor {
220
+ NSEvent.removeMonitor(monitor)
221
+ globalMouseMonitor = nil
222
+ }
223
+ if let monitor = localMouseMonitor {
224
+ NSEvent.removeMonitor(monitor)
225
+ localMouseMonitor = nil
226
+ }
227
+ }
228
+
229
+ // MARK: - Stdin Reader
230
+
231
+ private func startStdinReader() {
232
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
233
+ while let line = readLine() {
234
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
235
+ guard !trimmed.isEmpty else { continue }
236
+ guard let data = trimmed.data(using: .utf8),
237
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
238
+ let type = json["type"] as? String
239
+ else {
240
+ log("Skipping invalid JSON: \(trimmed)")
241
+ continue
242
+ }
243
+ DispatchQueue.main.async {
244
+ MainActor.assumeIsolated {
245
+ self?.handleCommand(type: type, json: json)
246
+ }
247
+ }
248
+ }
249
+ // stdin EOF — close window
250
+ DispatchQueue.main.async {
251
+ MainActor.assumeIsolated {
252
+ self?.closeAndExit()
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ // MARK: - Command Dispatch
259
+
260
+ func handleCommand(type: String, json: [String: Any]) {
261
+ switch type {
262
+ case "html":
263
+ guard let base64 = json["html"] as? String,
264
+ let htmlData = Data(base64Encoded: base64),
265
+ let html = String(data: htmlData, encoding: .utf8)
266
+ else {
267
+ log("html command: missing or invalid base64 payload")
268
+ return
269
+ }
270
+ webView.loadHTMLString(html, baseURL: nil)
271
+ case "eval":
272
+ guard let js = json["js"] as? String else {
273
+ log("eval command: missing js field")
274
+ return
275
+ }
276
+ webView.evaluateJavaScript(js, completionHandler: nil)
277
+ case "follow-cursor":
278
+ let enabled = json["enabled"] as? Bool ?? true
279
+ if enabled {
280
+ startFollowingCursor()
281
+ } else {
282
+ stopFollowingCursor()
283
+ }
284
+ case "file":
285
+ guard let path = json["path"] as? String else {
286
+ log("file command: missing path field")
287
+ return
288
+ }
289
+ let fileURL = URL(fileURLWithPath: path)
290
+ guard FileManager.default.fileExists(atPath: path) else {
291
+ log("file command: file not found: \(path)")
292
+ return
293
+ }
294
+ webView.loadFileURL(fileURL, allowingReadAccessTo: fileURL.deletingLastPathComponent())
295
+ case "close":
296
+ closeAndExit()
297
+ default:
298
+ log("Unknown command type: \(type)")
299
+ }
300
+ }
301
+
302
+ func closeAndExit() {
303
+ writeToStdout(["type": "closed"])
304
+ exit(0)
305
+ }
306
+
307
+ // MARK: - WKNavigationDelegate
308
+
309
+ nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
310
+ MainActor.assumeIsolated {
311
+ window.makeFirstResponder(webView)
312
+ writeToStdout(["type": "ready"])
313
+ }
314
+ }
315
+
316
+ // MARK: - WKScriptMessageHandler
317
+
318
+ nonisolated func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
319
+ MainActor.assumeIsolated {
320
+ guard let body = message.body as? String,
321
+ let data = body.data(using: .utf8),
322
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
323
+ else {
324
+ log("Received invalid message from webview")
325
+ return
326
+ }
327
+
328
+ if json["__glimpse_close"] as? Bool == true {
329
+ closeAndExit()
330
+ return
331
+ }
332
+
333
+ writeToStdout(["type": "message", "data": json])
334
+ if config.autoClose {
335
+ closeAndExit()
336
+ }
337
+ }
338
+ }
339
+
340
+ // MARK: - NSWindowDelegate
341
+
342
+ func windowWillClose(_ notification: Notification) {
343
+ writeToStdout(["type": "closed"])
344
+ exit(0)
345
+ }
346
+ }
347
+
348
+ // MARK: - Entry Point
349
+
350
+ let config = parseArgs()
351
+ let app = NSApplication.shared
352
+ let delegate = AppDelegate(config: config)
353
+ app.delegate = delegate
354
+ app.setActivationPolicy(.regular)
355
+ app.run()