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 +352 -0
- package/bin/glimpse.mjs +160 -0
- package/package.json +52 -0
- package/src/glimpse.mjs +159 -0
- package/src/glimpse.swift +355 -0
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
|
package/bin/glimpse.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/glimpse.mjs
ADDED
|
@@ -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()
|