bun-app-window 0.6.2 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # bun-app-window
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/bun-app-window?color=cb3837&logo=npm)](https://www.npmjs.com/package/bun-app-window)
4
- [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.4.3-f472b6?logo=bun&logoColor=white)](https://bun.sh)
4
+ [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.4.4-f472b6?logo=bun&logoColor=white)](https://bun.sh)
5
5
  [![TypeScript](https://img.shields.io/badge/TypeScript-%3E%3D7.0.2-3178C6?logo=typescript&logoColor=white)](#requirements--design-boundaries)
6
6
  [![Platform](https://img.shields.io/badge/Platform-Windows%20x64-0078D6?logo=windows&logoColor=white)](#requirements--design-boundaries)
7
7
  [![WebView2](https://img.shields.io/badge/WebView2-System%20Runtime-008080)](#requirements--design-boundaries)
@@ -26,13 +26,13 @@ await openShell({ url: `http://127.0.0.1:${server.port}`, flags: FLAG_FRAMELESS
26
26
 
27
27
  Before adopting this package, please review the following architectural constraints and trade-offs:
28
28
 
29
- | Constraint | Implication |
30
- | --- | --- |
31
- | Windows x64 only | Enforced via `os` and `cpu` fields in `package.json` (the platform-neutral package name is reserved for future expansion). A prebuilt `libshell.dll` is shipped in the package; no install-time compilation is required |
32
- | Bun runtime required | Ships raw TypeScript source files and requires Bun >= 1.4.3. While Node.js can install the package, it cannot execute it |
29
+ | Constraint | Implication |
30
+ | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
+ | Windows x64 only | Enforced via `os` and `cpu` fields in `package.json` (the platform-neutral package name is reserved for future expansion). A prebuilt `libshell.dll` is shipped in the package; no install-time compilation is required |
32
+ | Bun runtime required | Ships raw TypeScript source files and requires Bun >= 1.4.4. While Node.js can install the package, it cannot execute it |
33
33
  | TypeScript >= 7.0.2 to type-check it | The API is designed and checked on TypeScript 7.0.2, pinned exactly in `devDependencies`; no compatibility work is done for older compilers. Above 7.0.2 the outcome is unasserted — `bun-types` only opens its `typesVersions: ">= 7.1"` gate there, which changes what a `with { type: "text" }` import resolves to |
34
- | Type-checking needs `@types/bun` | Because the package ships `.ts` sources, your own `bun check` compiles them too. Add `bun add -d @types/bun`; without it the checker reports `TS2868: Cannot find name 'Bun'` from inside `node_modules` |
35
- | Built on `bun:ffi` | `bun:ffi` is marked as experimental in Bun. While Bun recommends Node-API for production stability, this library chooses `bun:ffi` to achieve a zero-dependency footprint |
34
+ | Type-checking needs `@types/bun` | Because the package ships `.ts` sources, your own `bun check` compiles them too. Add `bun add -d @types/bun`; without it the checker reports `TS2868: Cannot find name 'Bun'` from inside `node_modules` |
35
+ | Built on `bun:ffi` | `bun:ffi` is marked as experimental in Bun. While Bun recommends Node-API for production stability, this library chooses `bun:ffi` to achieve a zero-dependency footprint |
36
36
 
37
37
  Runtime prerequisites and non-goals:
38
38
 
@@ -48,7 +48,7 @@ bun add bun-app-window
48
48
 
49
49
  ### What the tarball contains
50
50
 
51
- The published package holds the prebuilt `dist/libshell.dll` plus the TypeScript sources — 21 files, roughly 0.4 MB packed. The native C++ layer (`native/`) and the vendored upstream headers (`vendor/`) are **not** shipped: they are only needed to rebuild `libshell.dll`, and rebuilding requires cloning the repository. `bun run build:native` and `build:exe` are therefore repository-only scripts that do nothing useful inside `node_modules`.
51
+ The published package holds the prebuilt `dist/libshell.dll` plus the TypeScript sources — 22 files, 0.50 MB packed (1.47 MB unpacked). The native C++ layer (`native/`) and the vendored upstream headers (`vendor/`) are **not** shipped: they are only needed to rebuild `libshell.dll`, and rebuilding requires cloning the repository. `bun run build:native` and `build:exe` are therefore repository-only scripts that do nothing useful inside `node_modules`.
52
52
 
53
53
  ## Quick Start
54
54
 
@@ -56,22 +56,23 @@ The published package holds the prebuilt `dist/libshell.dll` plus the TypeScript
56
56
  import { openShell, serveOnSafePort, FLAG_FRAMELESS, FLAG_CENTER } from "bun-app-window";
57
57
 
58
58
  const server = serveOnSafePort({
59
- fetch: () => new Response("<h1>Hello</h1>", {
60
- headers: { "content-type": "text/html; charset=utf-8" },
61
- }),
59
+ fetch: () =>
60
+ new Response("<h1>Hello</h1>", {
61
+ headers: { "content-type": "text/html; charset=utf-8" },
62
+ }),
62
63
  });
63
64
 
64
65
  const shell = await openShell({
65
- url: `http://127.0.0.1:${server.port}`,
66
- title: "My App",
67
- width: 1000,
68
- height: 680,
69
- flags: FLAG_FRAMELESS | FLAG_CENTER,
70
- minWidth: 480,
71
- minHeight: 320,
66
+ url: `http://127.0.0.1:${server.port}`,
67
+ title: "My App",
68
+ width: 1000,
69
+ height: 680,
70
+ flags: FLAG_FRAMELESS | FLAG_CENTER,
71
+ minWidth: 480,
72
+ minHeight: 320,
72
73
  });
73
74
 
74
- shell.post(JSON.stringify({ t: "ready" })); // Host -> Page one-way message
75
+ shell.post(JSON.stringify({ t: "ready" })); // Host -> Page one-way message
75
76
  shell.setTitle("Doing work…");
76
77
  ```
77
78
 
@@ -79,44 +80,51 @@ Note: `createShell(opts)` only resolves and loads the native DLL without opening
79
80
 
80
81
  ## Core API
81
82
 
83
+ `ackOf(shell, rc)` turns a bare status code into `{ rc, err }` and only reads `lastError()` on
84
+ failure — the native error slot is not cleared by a successful call, so reading it afterwards
85
+ returns a stale message that looks like a fresh failure.
86
+
82
87
  ### Instance Methods & Properties
83
88
 
84
- | Member | Description |
85
- | --- | --- |
86
- | `start()` | Opens the window and returns `{ rc, error }`. Returns immediately while the UI message loop runs on a dedicated native thread |
87
- | `stop()` | Destroys the window and releases native resources. Safe to call multiple times (idempotent) |
88
- | `show()` | Reveals the window. Typically paired with `FLAG_DEFER_SHOW` to display the window only after the web UI is ready |
89
- | `isAlive()` | Checks whether the native window still exists (unlike `state()`, which cannot distinguish between unstarted and destroyed states). To wait for close, use `pollEvents()` and watch for `CLOSED` instead of polling this |
90
- | `pollEvents(max?)` | Drains window lifecycle events (`STATE` / `CLOSED`); synchronous and consuming. See "Window events" below |
91
- | `state()` | Returns current window state: `{ maximized, minimized, focused, dark, raw }` |
92
- | `lastError()` | Returns the most recent failure message, or an empty string if no error occurred |
93
- | `setTitle` / `minimize` / `toggleMaximize` / `close` | Standard window control operations; each returns a status code `rc` |
94
- | `setMinSize(w, h)` | Sets the minimum window dimensions in logical pixels |
95
- | `drag()` | Initiates native window dragging from a page `pointerdown` event (preserves Windows Snap Layouts) |
96
- | `beginResize(edge)` | Initiates native border resizing from a page `pointermove` event (`edge` uses `RESIZE_EDGE.*` constants) |
97
- | `post(json)` | Sends a fire-and-forget JSON message from the host to the web page |
98
- | `setZoom(f)` / `getZoom()` | Gets or sets the page zoom factor (clamped automatically to `0.25`–`3.0`) |
99
- | `reload()` / `openDevTools()` / `toggleFullscreen()` | Equivalent to `Ctrl+R`, `F12`, and `F11` respectively |
100
- | `setBrowserUi(mask)` | Disables specific default browser behaviors using a bitwise OR of `BROWSER_UI_*` flags |
101
- | `showContextMenu(x, y, items)` | Displays a native Win32 context menu at the specified screen coordinates (see below) |
102
- | `pickFolder(title?)` | Opens a native folder selection dialog (synchronously blocks the calling thread; **when exposed over IPC, pair it with `timeoutMs: 0`**, see below) |
103
- | `libInfo` | Metadata of the resolved native library (file path, resolution source, SHA-256 hash) |
104
- | `symbols` | Escape hatch providing direct access to the raw `dlopen` FFI symbols |
89
+ | Member | Description |
90
+ | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
91
+ | `start()` | Opens the window and returns `{ rc, error }`. Returns immediately while the UI message loop runs on a dedicated native thread |
92
+ | `stop()` | Destroys the window and releases native resources. Safe to call multiple times (idempotent) |
93
+ | `show()` | Reveals the window. Typically paired with `FLAG_DEFER_SHOW` to display the window only after the web UI is ready |
94
+ | `isAlive()` | Checks whether the native window still exists (unlike `state()`, which cannot distinguish between unstarted and destroyed states). To wait for close, use `pollEvents()` and watch for `CLOSED` instead of polling this |
95
+ | `pollEvents(max?)` | Drains window lifecycle events (`STATE` / `CLOSED`); synchronous and consuming. See "Window events" below |
96
+ | `state()` | Returns current window state: `{ maximized, minimized, focused, dark, raw }` |
97
+ | `lastError()` | Returns the most recent failure message, or an empty string if no error occurred |
98
+ | `setTitle` / `minimize` / `toggleMaximize` / `close` | Standard window control operations; each returns a status code `rc` |
99
+ | `setMinSize(w, h)` | Sets the minimum window dimensions in logical pixels |
100
+ | `drag()` | Initiates native window dragging from a page `pointerdown` event (preserves Windows Snap Layouts) |
101
+ | `beginResize(edge)` | Initiates native border resizing from a page `pointermove` event (`edge` uses `RESIZE_EDGE.*` constants) |
102
+ | `post(json)` | Sends a fire-and-forget JSON message from the host to the web page |
103
+ | `setZoom(f)` / `getZoom()` | Gets or sets the page zoom factor (clamped automatically to `0.25`–`3.0`) |
104
+ | `reload()` / `openDevTools()` / `toggleFullscreen()` | Equivalent to `Ctrl+R`, `F12`, and `F11` respectively |
105
+ | `setFrameless(frameless)` / `isFrameless()` | Switches the window form at runtime and reports the current one. Only a window **created** with `FLAG_FRAMELESS` can switch; a framed-created window refuses with `rc=-1` and a reason in `lastError()`. See "Window Flags" below |
106
+ | `setBrowserUi(mask)` | Disables specific default browser behaviors using a bitwise OR of `BROWSER_UI_*` flags |
107
+ | `showContextMenu(x, y, items)` | Displays a native Win32 context menu at the specified screen coordinates (see below) |
108
+ | `pickFolder(title?)` | Opens a native folder selection dialog (synchronously blocks the calling thread; **when exposed over IPC, pair it with `timeoutMs: 0`**, see below) |
109
+ | `libInfo` | Metadata of the resolved native library (file path, resolution source, SHA-256 hash) |
110
+ | `symbols` | Escape hatch providing direct access to the raw `dlopen` FFI symbols |
105
111
 
106
112
  ### Window Flags
107
113
 
108
114
  Combine via bitwise OR (`|`) and pass to `opts.flags`:
109
115
 
110
- | Flag | Description |
111
- | --- | --- |
112
- | `FLAG_FRAMELESS` | Creates a frameless window without the default system title bar, delegating title bar rendering to the web UI |
113
- | `FLAG_RESIZABLE` | Allows user resizing |
114
- | `FLAG_CENTER` | Centers the window within the active monitor work area on startup (works in both standard and frameless modes) |
115
- | `FLAG_TOPMOST` | Keeps the window always on top |
116
- | `FLAG_DEFER_SHOW` | Creates the window hidden initially until `show()` is explicitly called |
117
- | `FLAG_ACCEPT_DROP` | Enables native file and folder drag-and-drop from Windows Explorer |
118
- | `FLAG_START_MAXIMIZED` | Opens the window directly in a maximized state without an intermediate normal-sized frame flash |
119
- | `FLAG_ALLOW_EXTERNAL_NAV` | Opts out of external-navigation interception — for apps that genuinely are browsers |
116
+ | Flag | Description |
117
+ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
118
+ | `FLAG_FRAMELESS` | Creates a frameless window without the default system title bar, delegating title bar rendering to the web UI. A window created this way can switch back to framed at runtime with `setFrameless(false)` |
119
+ | `FLAG_RESIZABLE` | Allows user resizing |
120
+ | `FLAG_CENTER` | Centers the window within the active monitor work area on startup (works in both standard and frameless modes) |
121
+ | `FLAG_TOPMOST` | Keeps the window always on top |
122
+ | `FLAG_DEFER_SHOW` | Creates the window hidden initially until `show()` is explicitly called |
123
+ | `FLAG_ACCEPT_DROP` | Enables native file and folder drag-and-drop from Windows Explorer |
124
+ | `FLAG_START_MAXIMIZED` | Opens the window directly in a maximized state without an intermediate normal-sized frame flash |
125
+ | `FLAG_ALLOW_EXTERNAL_NAV` | Opts out of external-navigation interception — for apps that genuinely are browsers |
126
+ | `FLAG_TITLEBAR_RESERVE` | In frameless mode, shifts the web content down by one title-bar height so the top strip belongs to the native window. Superseded by `FLAG_NATIVE_CAPTIONS`: the caption hit zone is pinned to client `y ∈ [0, 32)`, so this variant puts the page's own title bar 32 px away from where clicks are actually routed |
127
+ | `FLAG_NATIVE_CAPTIONS` | In frameless mode, draws the three caption buttons on a layered child window owned by the shell and answers `HTMAXBUTTON` there. This is the only way Snap Layouts can trigger: the WebView2 render child lives in another process and always returns `HTCLIENT`. Pair it with `mountTitleBar({ captions: "native" })` so the page reserves `46 × 3` instead of drawing its own buttons |
120
128
 
121
129
  ### External navigation is blocked by default
122
130
 
@@ -163,18 +171,18 @@ import htmlText from "./index.html" with { type: "text" };
163
171
  const html = htmlText as unknown as string;
164
172
 
165
173
  const app = await createApp({
166
- html,
167
- title: "My App",
168
- flags: FLAG_FRAMELESS, // opt in to a self-drawn title bar
169
- browserUi: BROWSER_UI_ALL,
170
- service: {
171
- greet: (name: string) => `hi ${name}`,
172
- pickFolder: () => app.shell.pickFolder(),
173
- },
174
+ html,
175
+ title: "My App",
176
+ flags: FLAG_FRAMELESS, // opt in to a self-drawn title bar
177
+ browserUi: BROWSER_UI_ALL,
178
+ service: {
179
+ greet: (name: string) => `hi ${name}`,
180
+ pickFolder: () => app.shell.pickFolder(),
181
+ },
174
182
  });
175
183
 
176
- await app.wait(); // resolves when the window closes
177
- await app.stop(); // releases the event pump and the server; idempotent
184
+ await app.wait(); // resolves when the window closes
185
+ await app.stop(); // releases the event pump and the server; idempotent
178
186
  ```
179
187
 
180
188
  Pass `page` to serve a bundled page script without writing a route yourself. `createApp` answers
@@ -202,7 +210,7 @@ Two more things on `App` that save a call and a subpath import:
202
210
  ```ts
203
211
  // page.ts — inside index.html's bundle
204
212
  import { connect } from "bun-app-window/ipc";
205
- const api = await connect(); // token comes from the page URL's ?t=
213
+ const api = await connect(); // token comes from the page URL's ?t=
206
214
  await api.greet("Ada");
207
215
  ```
208
216
 
@@ -218,6 +226,20 @@ What it does for you:
218
226
  `server.stop()`.
219
227
  - Starts exactly one window-event pump: `app.events`. It is also what waits for close, so it is
220
228
  the single consumer. Pass `{ events: app.events }` when you need `attachPlacementStore`.
229
+ - Reports page-side errors back to the host. Uncaught exceptions and unhandled rejections are
230
+ captured by an injected listener and posted over HTTP — not over the WebSocket, which does not
231
+ exist yet when a page script fails during evaluation. Pass `onPageError` to receive them; without
232
+ a callback they go to the host's stderr rather than being discarded.
233
+ - Checks `html` against `page` before starting. Supplying one without the other is a configuration
234
+ error whose only symptom is a window that renders and responds to nothing, so it throws. A
235
+ reference without `type="module"` produces a warning instead: the legal ways to write a
236
+ `<script>` tag are too varied for a string match to justify blocking startup.
237
+ - Serves extra static files with `assets`. A compiling framework usually emits more than one
238
+ artifact — typically a script plus a stylesheet — and `assets` is a path-to-text map with the
239
+ `content-type` inferred from the extension. Before this, every consumer hand-wrote a `routes`
240
+ callback for the stylesheet, and the usual mistake (a wrong MIME type) makes the browser drop the
241
+ stylesheet silently. Unrecognised extensions fall back to `application/octet-stream` rather than
242
+ guessing. A caller's `routes` still wins, and unregistered paths are 404.
221
243
  - Startup failures leave nothing half-started: if `openShell` throws, the bound port is released.
222
244
 
223
245
  Notes:
@@ -240,7 +262,7 @@ Notes:
240
262
  - **Not re-exported from the main entry.** It is a subpath like `./state` and `./ipc`. It imports
241
263
  `openShell` from `./index`; exporting it from `index.ts` would make that a cycle.
242
264
 
243
- This is **not** a replacement for `Bun.WebView`. That one is a *headless* browser for automation,
265
+ This is **not** a replacement for `Bun.WebView`. That one is a _headless_ browser for automation,
244
266
  testing and scraping — it has no window, and it can only screenshot its own page. This package
245
267
  draws a real desktop window with a system title bar, DPI awareness, snap layouts and taskbar
246
268
  identity. The two do not compete.
@@ -260,28 +282,46 @@ export type Events = { windowState: { maximized: boolean } };
260
282
  import { createIpcServer } from "bun-app-window/ipc";
261
283
 
262
284
  const ipc = createIpcServer({
263
- service: { greet: (name: string) => `Hello ${name}` },
285
+ service: { greet: (name: string) => `Hello ${name}` },
264
286
  });
265
287
 
266
288
  serveOnSafePort({
267
- fetch(req) { /* Serve assets & authenticate */ },
268
- websocket: {
269
- open: (ws) => ipc.attach(ws),
270
- message: (ws, raw) => ipc.handle(ws, raw),
271
- close: (ws) => ipc.detach(ws),
272
- },
289
+ fetch(req) {
290
+ /* Serve assets & authenticate */
291
+ },
292
+ websocket: {
293
+ open: (ws) => ipc.attach(ws),
294
+ message: (ws, raw) => ipc.handle(ws, raw),
295
+ close: (ws) => ipc.detach(ws),
296
+ },
273
297
  });
274
298
 
275
- ipc.emit("windowState", { maximized: false }); // Push event to connected pages
299
+ ipc.emit("windowState", { maximized: false }); // Push event to connected pages
276
300
  ```
277
301
 
302
+ #### Delivering state to late connections
303
+
304
+ A page loads _after_ the window is created. Anything the host emits between `createApp` returning
305
+ and the page's WebSocket opening is delivered to zero connections and is not replayed — so a page
306
+ that reads its initial state from events alone stays empty, with no error on either side.
307
+
308
+ Mark state-shaped events as sticky and the server replays the last payload to each new connection:
309
+
310
+ ```ts
311
+ ipc.emit("windowState", { maximized: false }, { sticky: true }); // Replayed on attach()
312
+ ```
313
+
314
+ Sticky is **opt-in** and keeps one payload per event name, not a history. Events that are
315
+ notifications rather than state should stay non-sticky: a late connection would otherwise receive
316
+ one stale message that reads like a fresh one.
317
+
278
318
  ```ts
279
319
  // Page
280
320
  import { connect } from "bun-app-window/ipc";
281
321
  import type { Api, Events } from "./api";
282
322
 
283
323
  const api = await connect<Api, Events>();
284
- await api.greet("Ada"); // Inferred return type: string
324
+ await api.greet("Ada"); // Inferred return type: string
285
325
  api.on("windowState", (s) => bar.setMaximized(s.maximized));
286
326
  ```
287
327
 
@@ -312,17 +352,22 @@ The client is a `Proxy`: a handful of names resolve to the client itself and nev
312
352
  method. If your service exposes a method under one of these names, **the server-side method becomes
313
353
  unreachable** — the page calls the client's own member instead, and neither end reports an error.
314
354
 
315
- | Name | What the page actually gets |
316
- | --- | --- |
317
- | `on` / `off` | Event subscription helpers |
318
- | `close()` | Closes the WebSocket channel — every later call is rejected |
319
- | `connected` | `boolean`: whether the channel is open |
320
- | `pending` | `number` of calls still awaiting a reply |
321
- | `then` | `undefined`, so `await client` cannot hang on a never-resolving thenable |
355
+ | Name | What the page actually gets |
356
+ | ------------ | ------------------------------------------------------------------------ |
357
+ | `on` / `off` | Event subscription helpers |
358
+ | `close()` | Closes the WebSocket channel — every later call is rejected |
359
+ | `connected` | `boolean`: whether the channel is open |
360
+ | `pending` | `number` of calls still awaiting a reply |
361
+ | `then` | `undefined`, so `await client` cannot hang on a never-resolving thenable |
322
362
 
323
363
  Symbol keys are reserved as well. Pick a different service name (`winClose` instead of `close`) and
324
364
  map it back on the page side — see the title bar section below.
325
365
 
366
+ Because the failure above is invisible at runtime, `createIpcServer` **throws at creation time** when
367
+ the service table contains any of these names. The error names the offending keys and suggests a
368
+ replacement. Pass `{ rejectReservedNames: false }` only when the table is generated from external
369
+ input and validated by other means.
370
+
326
371
  ### `bun-app-window/titlebar` — Windows 11 Title Bar
327
372
 
328
373
  Mounts a customizable Windows 11 title bar inside frameless windows. The IPC service is **flat** —
@@ -332,12 +377,12 @@ so the host exposes `winClose` and the page maps it back:
332
377
  ```ts
333
378
  // Host — flat service; `close` is reserved by the IPC client
334
379
  createIpcServer({
335
- service: {
336
- minimize: () => shell.minimize(),
337
- toggleMaximize: () => shell.toggleMaximize(),
338
- winClose: () => shell.close(),
339
- drag: () => shell.drag(),
340
- },
380
+ service: {
381
+ minimize: () => shell.minimize(),
382
+ toggleMaximize: () => shell.toggleMaximize(),
383
+ winClose: () => shell.close(),
384
+ drag: () => shell.drag(),
385
+ },
341
386
  });
342
387
  ```
343
388
 
@@ -346,14 +391,14 @@ createIpcServer({
346
391
  import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
347
392
 
348
393
  const bar = mountTitleBar({
349
- title: "My App",
350
- icon: "/icon.svg",
351
- bridge: bridgeFromWindowService({
352
- minimize: () => api.minimize(),
353
- toggleMaximize: () => api.toggleMaximize(),
354
- close: () => api.winClose(), // mapped back to the bridge's own name
355
- drag: () => api.drag(),
356
- }),
394
+ title: "My App",
395
+ icon: "/icon.svg",
396
+ bridge: bridgeFromWindowService({
397
+ minimize: () => api.minimize(),
398
+ toggleMaximize: () => api.toggleMaximize(),
399
+ close: () => api.winClose(), // mapped back to the bridge's own name
400
+ drag: () => api.drag(),
401
+ }),
357
402
  });
358
403
 
359
404
  api.on("windowState", (s) => bar.setMaximized(s.maximized));
@@ -362,7 +407,9 @@ api.on("windowState", (s) => bar.setMaximized(s.maximized));
362
407
  Implementation notes:
363
408
 
364
409
  - Native dragging: Calling `shell.drag()` dispatches a native `WM_NCLBUTTONDOWN` (`HTCAPTION`) message, preserving Windows 11 Snap Layouts and double-click maximize behavior.
365
- - Theming & icons: Colors and dimensions are controlled via CSS custom properties (`--ws-tb-*`). Window control buttons default to geometric glyphs for universal compatibility; setting `icons: "fluent"` switches to `Segoe Fluent Icons` (note: missing fonts on older systems will render as missing-glyph boxes).
410
+ - Theming: Colors and dimensions are controlled via CSS custom properties (`--ws-tb-*`). `titleBarCss()` is exported if you need to inject the stylesheet yourself instead of letting `mountTitleBar` do it once.
411
+ - Glyphs: the default is `detectIconSet()`, which asks `document.fonts` for `Segoe Fluent Icons` (Win 11) or `Segoe MDL2 Assets` (Win 10) and picks `fluent` when either is present — the same code points exist in both fonts, so one table covers both systems. It only falls back to the `unicode` geometric set (`─ □ ❐ ✕`) when neither font is found. Pass `icons: "unicode" | "fluent"` to override the probe.
412
+ - Let the shell draw the buttons: pass `captions: "native"` and the component renders **no** buttons at all, reserving `46 × 3` on the right for the shell's own layered child window. Pair it with `FLAG_NATIVE_CAPTIONS` — enabling only one of the two gives you either two overlapping button sets or an empty strip that cannot be clicked. Note that clicks on those buttons arrive as non-client messages, so `bridge.minimize` / `toggleMaximize` / `close` are not called on the mouse path; the shell performs the action itself. Keyboard Tab + Enter still goes through the bridge.
366
413
 
367
414
  ### `bun-app-window/state` — Window Placement Persistence
368
415
 
@@ -379,7 +426,7 @@ if (saved) restorePlacement(shell, saved);
379
426
 
380
427
  shell.show();
381
428
 
382
- const store = attachPlacementStore(shell, FILE); // Subscribes to window events; writes once the drag settles
429
+ const store = attachPlacementStore(shell, FILE); // Subscribes to window events; writes once the drag settles
383
430
  process.on("beforeExit", () => store.flush());
384
431
  ```
385
432
 
@@ -417,8 +464,10 @@ event stream directly:
417
464
  import { EVENT_CLOSED, EVENT_STATE } from "bun-app-window/events";
418
465
 
419
466
  for (const e of shell.pollEvents()) {
420
- if (e.type === EVENT_CLOSED) break; // window is gone, the native singleton is released
421
- if (e.type === EVENT_STATE && e.maximized) { /* ... */ }
467
+ if (e.type === EVENT_CLOSED) break; // window is gone, the native singleton is released
468
+ if (e.type === EVENT_STATE && e.maximized) {
469
+ /* ... */
470
+ }
422
471
  }
423
472
  ```
424
473
 
@@ -430,7 +479,9 @@ exactly one consumer. For a fixed cadence plus multi-subscriber fan-out, use
430
479
  import { EVENT_CLOSED, attachEventPump } from "bun-app-window/events";
431
480
 
432
481
  const pump = attachEventPump(shell, { intervalMs: 16 });
433
- pump.on((e) => { if (e.type === EVENT_CLOSED) pump.stop(); });
482
+ pump.on((e) => {
483
+ if (e.type === EVENT_CLOSED) pump.stop();
484
+ });
434
485
  pump.onGap((missing) => console.warn(`missed ${missing} events`));
435
486
  ```
436
487
 
@@ -452,8 +503,8 @@ Exposes internal metrics and hit-testing utilities. Note: This module is exclude
452
503
  import { debugOf } from "bun-app-window/debug";
453
504
 
454
505
  const d = debugOf(shell);
455
- d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
456
- d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
506
+ d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
507
+ d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
457
508
  d.hittest(x, y);
458
509
  ```
459
510
 
@@ -462,8 +513,13 @@ must; do not put a parser in production code, because the layout is not part of
462
513
 
463
514
  ### `bun-app-window/types` — Constants, Safe for the Browser
464
515
 
465
- A zero-dependency module holding the whole constant surface: `FLAG_*`, `BROWSER_UI_*`, `RESIZE_EDGE`,
466
- `RESIZE_INDEX`, `ZOOM_LADDER`, `ZOOM_MIN`, `ZOOM_MAX`, `STATE_*`, and the related types.
516
+ A zero-dependency module holding the whole constant surface: `FLAG_*` (all ten, including
517
+ `FLAG_TITLEBAR_RESERVE` and `FLAG_NATIVE_CAPTIONS`), `BROWSER_UI_*`, `RESIZE_EDGE`, `RESIZE_INDEX`,
518
+ `resizeIndex`, `ZOOM_LADDER`, `ZOOM_MIN`, `ZOOM_MAX`, `STATE_MAXIMIZED` / `STATE_MINIMIZED` /
519
+ `STATE_FOCUSED`, and the caption geometry trio `CAPTION_BAR_HEIGHT` (32) / `CAPTION_BUTTON_WIDTH` (46)
520
+ / `CAPTION_BUTTONS` — the last of which is ordered `close`, `maximize`, `minimize` from right to left,
521
+ and that order is load-bearing: if a page draws its buttons in a different order than
522
+ `caption_button_at()` reports them, clicking "close" maximizes the window.
467
523
 
468
524
  **Use this subpath on the page side.** The main entry imports `bun:ffi` through `./native`, which a
469
525
  browser bundle cannot resolve — so `import { BROWSER_UI_ALL } from "bun-app-window"` inside page code
@@ -497,6 +553,60 @@ await writePageBundle({ entry: "src/ui/page.ts", out: "src/ui/page.bundle.txt" }
497
553
  `bytes` is a UTF-8 byte count, not `string.length`; the two differ on any source containing non-ASCII
498
554
  text, and a reported size that only looks right is worse than no reported size.
499
555
 
556
+ #### Compiling frameworks: `plugins`, `define`, `loader`
557
+
558
+ These three are passed straight through to `Bun.build`; nothing about them is specific to any
559
+ framework, and this package ships no plugins of its own.
560
+
561
+ ```ts
562
+ const built = await bundlePage({
563
+ entry: "src/main.ts",
564
+ plugins: [myFrameworkPlugin()], // .vue / .svelte / .tsx — whatever your plugin handles
565
+ define: { __VUE_OPTIONS_API__: "false" }, // compile-time constants
566
+ loader: { ".thing": "ts" }, // custom extensions
567
+ });
568
+ ```
569
+
570
+ #### `alias` is implemented here, because Bun has no such option
571
+
572
+ `BuildConfig` in Bun 1.4.4 has no `alias` property, and passing one is not an error — it is silently
573
+ ignored. Four forms were tried and all failed to resolve. So this package backs `alias` with its own
574
+ `onResolve` plugin:
575
+
576
+ ```ts
577
+ await bundlePage({ entry, alias: { "@": "./src", lib: "./src/lib" } });
578
+ ```
579
+
580
+ | Specifier | Rewritten? |
581
+ | ------------------------------------- | ---------- |
582
+ | equal to a key, or prefixed by `key/` | yes |
583
+ | starts with `.` or `/` | no |
584
+ | has a `scheme:` prefix | no |
585
+
586
+ **The default is empty.** `$lib` is a SvelteKit convention and `@/` is some project's `tsconfig`
587
+ alias; neither belongs here. Aliases merge with your plugins rather than replacing them, and the
588
+ plugin is only registered when you pass `alias`.
589
+
590
+ #### Multiple outputs
591
+
592
+ A compiling framework often produces more than one artifact — a script plus a stylesheet is the
593
+ common shape. `outputs` lists all of them with `path` / `kind` / `code` / `bytes`, and `code` /
594
+ `bytes` refer to the `entry-point` artifact specifically (not `outputs[0]`, because output order is
595
+ not a contract).
596
+
597
+ `writePageBundle` writes every artifact: the entry to `out`, the rest to the same directory with the
598
+ extension substituted. `page.bundle.txt` therefore yields `page.bundle.css`, **not** `main.css` — the
599
+ entry's filename must not leak into a path that HTML and `assets` reference. It returns
600
+ `written: Array<{ path, bytes }>` so cleanup removes every file it created.
601
+
602
+ ```text
603
+ $ bun run bundle:page
604
+ 109,888 B dist/page.bundle.txt ← entry-point
605
+ 133 B dist/page.bundle.css ← asset
606
+ ```
607
+
608
+ Serve the extra files with `createApp({ assets })` — see the `app` section above.
609
+
500
610
  Failure throws with Bun's full diagnostics. Both shapes are handled: compile-time problems come back
501
611
  as `{ success: false, logs }`, while an unreachable entry point **throws an `AggregateError`** whose
502
612
  `message` is only `Bundle failed` and whose detail lives on `.errors`.
@@ -527,13 +637,13 @@ const shell = await openShell({ url, browserUi: BROWSER_UI_ALL });
527
637
 
528
638
  Individual flags can also be combined via bitwise OR:
529
639
 
530
- | Flag | Disabled Behavior |
531
- | --- | --- |
532
- | `BROWSER_UI_MENU` | Default Edge right-click context menu |
533
- | `BROWSER_UI_KEYS` | Built-in browser shortcuts (`Ctrl+P`, `Ctrl+F`, `Ctrl+S`) |
534
- | `BROWSER_UI_STATUSBAR` | Bottom-left URL hover status bar |
535
- | `BROWSER_UI_SWIPE` | Trackpad horizontal swipe back/forward navigation |
536
- | `BROWSER_UI_AUTOFILL` | Form autofill popups |
640
+ | Flag | Disabled Behavior |
641
+ | ---------------------- | --------------------------------------------------------- |
642
+ | `BROWSER_UI_MENU` | Default Edge right-click context menu |
643
+ | `BROWSER_UI_KEYS` | Built-in browser shortcuts (`Ctrl+P`, `Ctrl+F`, `Ctrl+S`) |
644
+ | `BROWSER_UI_STATUSBAR` | Bottom-left URL hover status bar |
645
+ | `BROWSER_UI_SWIPE` | Trackpad horizontal swipe back/forward navigation |
646
+ | `BROWSER_UI_AUTOFILL` | Form autofill popups |
537
647
 
538
648
  Note: `F11`, `F12`, and `Ctrl±` zoom shortcuts are handled directly by the native shell and remain active regardless of these flags.
539
649
 
@@ -544,26 +654,22 @@ After disabling the default browser menu, render native Win32 popup menus (`Trac
544
654
  ```ts
545
655
  // Host: expose the menu capability on the flat service
546
656
  createIpcServer({
547
- service: {
548
- showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
549
- },
657
+ service: {
658
+ showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
659
+ },
550
660
  });
551
661
  ```
552
662
 
553
663
  ```ts
554
664
  // Page: trigger the native menu
555
665
  document.addEventListener("contextmenu", async (e) => {
556
- e.preventDefault();
557
- await api.showContextMenu(
558
- Math.round(e.screenX),
559
- Math.round(e.screenY),
560
- "open|Open\nrefresh|Refresh\n-\nquit|Quit"
561
- );
666
+ e.preventDefault();
667
+ await api.showContextMenu(Math.round(e.screenX), Math.round(e.screenY), "open|Open\nrefresh|Refresh\n-\nquit|Quit");
562
668
  });
563
669
 
564
670
  // Page: handle menu item selection
565
671
  window.addEventListener("shell-msg", (e) => {
566
- if (e.detail.t === "menu") console.log("Selected ID:", e.detail.id);
672
+ if (e.detail.t === "menu") console.log("Selected ID:", e.detail.id);
567
673
  });
568
674
  ```
569
675
 
@@ -583,7 +689,7 @@ const shell = await openShell({ url, flags: FLAG_ACCEPT_DROP });
583
689
  ```js
584
690
  // Page
585
691
  window.addEventListener("shell-msg", (e) => {
586
- if (e.detail.t === "drop") console.log("Dropped paths:", e.detail.paths);
692
+ if (e.detail.t === "drop") console.log("Dropped paths:", e.detail.paths);
587
693
  });
588
694
  ```
589
695
 
@@ -591,10 +697,10 @@ Files **and folders** are forwarded by a script the shell injects, so the page d
591
697
 
592
698
  ```js
593
699
  window.addEventListener("shell-msg", (e) => {
594
- if (e.detail.t !== "drop") return;
595
- for (const p of e.detail.paths) {
596
- console.log(e.detail.dirs.includes(p) ? "folder" : "file", p);
597
- }
700
+ if (e.detail.t !== "drop") return;
701
+ for (const p of e.detail.paths) {
702
+ console.log(e.detail.dirs.includes(p) ? "folder" : "file", p);
703
+ }
598
704
  });
599
705
  ```
600
706
 
@@ -605,14 +711,27 @@ When running in an elevated administrator process (High Integrity Level) while E
605
711
  The shell does not inject any implicit DOM nodes or stylesheets into your page. To make your web UI feel like a native desktop app, include the following baseline rules:
606
712
 
607
713
  ```css
608
- html, body { cursor: default; }
609
- body { user-select: none; }
610
- input, textarea, .log { user-select: text; cursor: auto; }
611
- button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
714
+ html,
715
+ body {
716
+ cursor: default;
717
+ }
718
+ body {
719
+ user-select: none;
720
+ }
721
+ input,
722
+ textarea,
723
+ .log {
724
+ user-select: text;
725
+ cursor: auto;
726
+ }
727
+ button:focus-visible {
728
+ outline: 2px solid var(--accent);
729
+ outline-offset: 2px;
730
+ }
612
731
  ```
613
732
 
614
733
  ```html
615
- <input type="text" spellcheck="false" autocomplete="off" autocapitalize="off">
734
+ <input type="text" spellcheck="false" autocomplete="off" autocapitalize="off" />
616
735
  ```
617
736
 
618
737
  Setting `spellcheck="false"` is particularly important—red squiggly underlines beneath file paths and numeric inputs immediately give away a web view.
@@ -623,7 +742,7 @@ When bundling with `bun build --compile`, import `bun-app-window/embedded` to em
623
742
 
624
743
  ```ts
625
744
  import { createShell } from "bun-app-window";
626
- import { embeddedDll } from "bun-app-window/embedded"; // Embeds the DLL into the binary
745
+ import { embeddedDll } from "bun-app-window/embedded"; // Embeds the DLL into the binary
627
746
 
628
747
  const shell = await createShell({ url, embedded: embeddedDll });
629
748
  ```
@@ -663,18 +782,18 @@ At runtime, `libshell.dll` is located in the following order:
663
782
 
664
783
  ### Built-in Keyboard & Mouse Shortcuts
665
784
 
666
- | Input | Behavior |
667
- | --- | --- |
668
- | `Ctrl +` / `Ctrl -` / `Ctrl 0` | Zoom in / Zoom out / Reset zoom to 100% |
669
- | `Ctrl + Mouse Wheel` | Zoom in / Zoom out |
670
- | `F11` | Toggle fullscreen mode (hides window borders and covers the taskbar) |
671
- | `F5` / `Ctrl + R` | Reload page |
672
- | `F12` | Open WebView2 Developer Tools |
673
- | `Alt + Space` / Right-click title bar | Open native Windows system menu |
785
+ | Input | Behavior |
786
+ | ------------------------------------- | -------------------------------------------------------------------- |
787
+ | `Ctrl +` / `Ctrl -` / `Ctrl 0` | Zoom in / Zoom out / Reset zoom to 100% |
788
+ | `Ctrl + Mouse Wheel` | Zoom in / Zoom out |
789
+ | `F11` | Toggle fullscreen mode (hides window borders and covers the taskbar) |
790
+ | `F5` / `Ctrl + R` | Reload page |
791
+ | `F12` | Open WebView2 Developer Tools |
792
+ | `Alt + Space` / Right-click title bar | Open native Windows system menu |
674
793
 
675
794
  ## Development & Verification
676
795
 
677
- Built with Bun 1.4.3 and TypeScript 7.0.2 with full strict type-checking enabled.
796
+ Built with Bun 1.4.4 and TypeScript 7.0.2 with full strict type-checking enabled.
678
797
 
679
798
  **This project has no remote CI.** GitHub Actions cannot allocate runners for this account (billing lock), and the project is a single-platform, single-developer package with no external service dependencies — so the gates run locally instead. The recommended entry point is:
680
799
 
@@ -683,9 +802,9 @@ bun run scripts/gate.ts # bun check + bun test + consumer acceptanc
683
802
  bun run scripts/gate.ts --clean # same gates, but inside a fresh clone of HEAD
684
803
  ```
685
804
 
686
- `-Clean` reproduces the conditions a hosted runner would provide: it clones `HEAD` into `%TEMP%`, installs from the lockfile, and runs the gates there, so local artefacts (`.build/`, `node_modules/`, `vendor/webview2/`) cannot mask a missing file. It takes about two seconds. Uncommitted changes are reported and excluded — run without `-Clean` to verify the working tree as-is.
805
+ `-Clean` reproduces the conditions a hosted runner would provide: it clones `HEAD` into `%TEMP%`, installs from the lockfile, and runs the gates there, so local artefacts (`.build/`, `node_modules/`, `vendor/webview2/`) cannot mask a missing file. Uncommitted changes are reported and excluded — run without `-Clean` to verify the working tree as-is.
687
806
 
688
- The gate has three stages: `bun check`, `bun test`, and consumer acceptance (`scripts/verify-pack.ts`). The third packs the tarball, installs it into a scratch project under `%TEMP%`, type-checks that project, and runs `scripts/consumer-probe.ts` against the installed package — assertions covering every subpath, the DLL actually being `dlopen`-ed out of `node_modules`, and `libInfo.source === "package"` (the probe reports its own count). It runs offline in about 0.4 s and denies by default: a file packed without being listed in the allow-list fails the gate. Pass `-SkipPack` only when the gate itself is what you are changing.
807
+ The gate has three stages: `bun check`, `bun test`, and consumer acceptance (`scripts/verify-pack.ts`). The third packs the tarball, installs it into a scratch project under `%TEMP%`, type-checks that project, and runs `scripts/consumer-probe.ts` against the installed package — assertions covering every subpath, the DLL actually being `dlopen`-ed out of `node_modules`, and `libInfo.source === "package"` (the probe reports its own count). It runs offline and denies by default: a file packed without being listed in the allow-list fails the gate. Pass `-SkipPack` only when the gate itself is what you are changing.
689
808
 
690
809
  The underlying commands remain available directly:
691
810
 
@@ -695,16 +814,16 @@ bun test # Run automated test suite (tests/)
695
814
  bun run demo # Launch interactive self-test demo (requires desktop session)
696
815
  ```
697
816
 
698
- Note that `bun run demo` and the compiled-executable acceptance (shape B in `AGENTS.md`) both need an interactive desktop, so they were never CI-capable — they are local-only by construction.
817
+ Note that `bun run demo` and the single-file `.exe` acceptance both need an interactive desktop, so they were never CI-capable — they are local-only by construction. For the `.exe`, the whole check is: compile it, copy the result into an empty directory, launch it, and confirm the window comes up with `libInfo.source === "embedded"`.
699
818
 
700
- Whenever `src/shell/` or `package.json` is modified, the consumer acceptance stage already covers the installed-package path (shape A in `AGENTS.md`); it runs as part of the gate, so there is nothing to remember. Only shape B — building the single-file `.exe` — still needs a manual run inside a desktop session.
819
+ Whenever `src/shell/` or `package.json` is modified, the consumer acceptance stage already covers the installed-package path; it runs as part of the gate, so there is nothing to remember. Only the `.exe` build still needs a manual run inside a desktop session.
701
820
 
702
821
  For window enumeration (`EnumWindows`) or automated screenshot scripts, filter by the corresponding Win32 window class name:
703
822
 
704
- | Window Mode | Window Class Name |
705
- | --- | --- |
706
- | Standard framed (without `FLAG_FRAMELESS`) | `webview` |
707
- | Frameless (with `FLAG_FRAMELESS`) | `webview_shell` |
823
+ | Window Mode | Window Class Name |
824
+ | ------------------------------------------ | ----------------- |
825
+ | Standard framed (without `FLAG_FRAMELESS`) | `webview` |
826
+ | Frameless (with `FLAG_FRAMELESS`) | `webview_shell` |
708
827
 
709
828
  ## Acknowledgments
710
829