bun-app-window 0.3.0 → 0.4.1
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/CHANGELOG.md +110 -2
- package/README.md +201 -32
- package/README.zh-CN.md +182 -32
- package/README.zh-TW.md +183 -32
- package/dist/libshell.dll +0 -0
- package/package.json +3 -1
- package/src/shell/app.ts +344 -0
- package/src/shell/events.ts +297 -0
- package/src/shell/index.ts +37 -0
- package/src/shell/native.ts +4 -2
- package/src/shell/state.ts +115 -21
- package/src/ui/titlebar.ts +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,113 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [0.4.1] - 2026-10-21
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`bun-app-window/titlebar` no longer breaks `bun check` in your project.** Every consumer whose `tsconfig.json` includes `DOM` — that is, every project with a real page — was stopped by a `TS2352` error pointing at this package's own source, with no way to work around it:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
node_modules/bun-app-window/src/ui/titlebar.ts(237,27): error TS2352:
|
|
15
|
+
Conversion of type 'typeof globalThis' to type '{ document?: TitleBarDocument; }'
|
|
16
|
+
may be a mistake because neither type sufficiently overlaps with the other.
|
|
17
|
+
If this was intentional, convert the expression to 'unknown' first.
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`skipLibCheck: true` does not help, because this package ships `.ts` sources rather than `.d.ts` files, so the error is in compiled source and not in a declaration. The component reached `globalThis.document` through a single-layer type assertion; with `DOM` loaded, `globalThis.document` is a full `Document`, and the component's deliberately minimal `TitleBarDocument` does not overlap with it enough to satisfy the checker. It is now a two-layer assertion (`as unknown as`), which states the intent without making the component depend on DOM types. Runtime behaviour is unchanged. Existing code needs no edits — upgrading is enough.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **The three READMEs are corrected in six places.** Everything below was documentation, not code, but each item could cost a consumer real time by making them follow an example that does not work. The pages are `README.md`, `README.zh-CN.md` and `README.zh-TW.md`, and all six corrections are applied to all three.
|
|
25
|
+
|
|
26
|
+
**The `titlebar` example contradicted itself.** One section showed the IPC service flat, another showed it nested under a `window` key — inside the same file. The nested form never worked: `service` is a flat method table. The example now uses the flat form, and because `close` is one of the client's reserved names, it exposes `winClose` and maps it back on the page side.
|
|
27
|
+
|
|
28
|
+
**`debug.metrics()` and `debug.dpi()` were described as if they returned objects.** They return formatted text — `pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1` and `awareness=-1 dpi=192 scale=200%`. The example shows the real output now, and says not to build a parser on it: the layout is not part of any contract.
|
|
29
|
+
|
|
30
|
+
**`await app.wait()` was shown as the end of a program.** It resolves when the window closes but stops neither the event pump nor the server, and the pump's timer is pinned on purpose — so a program that ends on that line stays alive after the window is gone. The example follows it with `await app.stop()`, which is idempotent and returns immediately once the close has been observed.
|
|
31
|
+
|
|
32
|
+
**The IPC client's reserved names were undocumented.** `on`, `off`, `close`, `connected`, `pending` and `then` resolve to the client itself and never reach a remote method. A service that defines one of them is silently unreachable, with no error on either end — and `close` in particular also tears down the channel for every later call. There is a table of them now, and a note that symbol keys are reserved as well.
|
|
33
|
+
|
|
34
|
+
**Two traps that pass type checking but do not work** are documented together: the page bundle is an ES module, so a classic `<script>` tag executes nothing while the page still renders; and the title bar listens for `document` clicks on `data-act`, so a host binding `button[data-act]` receives every click twice.
|
|
35
|
+
|
|
36
|
+
**The tarball figures were stale** — 19 files / ~0.36 MB against the measured 21 files / ~0.4 MB.
|
|
37
|
+
|
|
38
|
+
- **The `app` Quick Start no longer reads the HTML at run time.** The old snippet, `import html from "./index.html" with { type: "text" }`, did not compile under TypeScript 7.0.2, because `bun-types` gates the declaration that types this import behind `typesVersions: ">= 7.1"`. The obvious-looking repair, `await Bun.file(...).text()`, would have broken the property that section exists to demonstrate: a compile-time constant travels inside `bun build --compile`, a run-time read does not. The import stays and only the type is bridged, so a single-file executable still carries its HTML:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import htmlText from "./index.html" with { type: "text" };
|
|
42
|
+
const html = htmlText as unknown as string;
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Note
|
|
46
|
+
|
|
47
|
+
- **Nothing to do on upgrade.** No public API changed: the exported surface, the flags, the IPC protocol and `dist/libshell.dll` are byte-for-byte the same as 0.4.0, so `bun update bun-app-window` is the whole procedure. This is a patch because the only code change is the one assertion above, and the rest is documentation.
|
|
48
|
+
|
|
49
|
+
## [0.4.0] - 2026-10-21
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- `bun-app-window/events` — window lifecycle events, and the end of polling. Until this release the host had exactly one way to learn that the window changed: ask again. `shell.isAlive()` every 50 ms to notice a close, and `attachPlacementStore` calling `getBounds()` every 700 ms. The module comment in `state.ts` gave the reason — "the native side pushes window state to the **page** (shell-msg); the host cannot see it, and adding a native→host callback channel means a whole lifecycle to manage".
|
|
54
|
+
|
|
55
|
+
A callback is not available: `webview_run` blocks the native UI thread while `bun:ffi`'s `JSCallback` needs an idle JS thread, and the two together deadlock (ADR 0002). So the channel is a fixed ring plus a pull:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
native: UI thread appends 32-byte records to a 128-slot ring,
|
|
59
|
+
holding one SRWLOCK for at most a 4 KiB copy
|
|
60
|
+
host: shell_poll_events(buf, capacity) → shell.pollEvents() / attachEventPump()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Four decisions, each with a failure mode behind it:
|
|
64
|
+
|
|
65
|
+
**Capacity 128, drop the oldest.** A one-second drag at a 16 ms cadence is about 60 events, so 128 is a two-fold margin. Dropping the *newest* instead would freeze the ring at a stale state and the host would see "the window never changed" — far worse than losing history. Losses are reported as gaps in `seq` rather than synthesised into an overflow event: the gap carries the exact count, and no new `seq` special case is needed.
|
|
66
|
+
|
|
67
|
+
**`CLOSED` is emitted after teardown completes.** That is the whole point: "saw `CLOSED`" must be equivalent to "the process-wide singleton is released, restart is safe". It follows that `shell_poll_events` must **not** return -1 when `g_webview` is null, unlike every other export — `CLOSED` is produced precisely after the handle is gone.
|
|
68
|
+
|
|
69
|
+
**Both window procedures enqueue.** `shell_wnd_proc` is only registered for the `webview_shell` class, which only `FLAG_FRAMELESS` uses. A framed window is created by the webview library, and only the `toplevel_proc` subclass sees its `WM_SIZE`. Enqueueing next to `push_state()` alone would leave the **default** configuration permanently silent about size and maximize events, with every test green.
|
|
70
|
+
|
|
71
|
+
**The event side keeps its own change-detection cache.** `push_state` compares against `g_pushed` to throttle cross-process `webview_eval`. Recording the same state into the ring is a memory write, and the consumers differ: sharing the cache would let the page push's "unchanged, skip" swallow the host's events too.
|
|
72
|
+
|
|
73
|
+
- `shell.pollEvents(max?)` on the main entry — the raw pull primitive. Synchronous, consuming, and documented as single-consumer: drained records never come back, so a process should have exactly one reader.
|
|
74
|
+
|
|
75
|
+
- `EventCursor` and `attachEventPump()` — the cursor turns `seq` into "how many did I miss", and the pump supplies a cadence with multi-subscriber fan-out (`on()` / `onGap()`), stopping itself once `CLOSED` arrives.
|
|
76
|
+
|
|
77
|
+
- `bun-app-window/app` — an application skeleton, and the answer to a specific number: **180**. That is how much host boilerplate a first app needed before this release, against roughly 30 lines of business code. Picking a browser-safe port, minting a per-run auth token, handling the WebSocket upgrade, serving HTML as a string, wiring `createIpcServer` into Bun's websocket callbacks, composing window flags, handling `openShell` failure, waiting for window close, and calling `stop()` on two things — each piece is easy, but they are only correct in one order. `createApp()` collapses it:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const app = await createApp({ html, service: { greet: (n) => `hi ${n}` } });
|
|
81
|
+
await app.wait();
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Three decisions worth recording:
|
|
85
|
+
|
|
86
|
+
**It does not default to `FLAG_FRAMELESS`.** A frameless window has no system title bar, so it cannot be dragged until the page mounts `mountTitleBar`. A skeleton whose default value breaks the "hello world" case is worse than no skeleton, so the default is `FLAG_RESIZABLE | FLAG_CENTER` — the side that works even when the page does nothing.
|
|
87
|
+
|
|
88
|
+
**It is not re-exported from the main entry.** `app.ts` imports `openShell` from `./index`; exporting it back from `index.ts` would make that a cycle. It is a subpath like `./state` and `./ipc`, and a static assertion in `tests/app.test.ts` keeps it that way.
|
|
89
|
+
|
|
90
|
+
**Static assets are inline strings only.** `import html from "./index.html" with { type: "text" }` yields a compile-time constant: nothing touches the filesystem, no temp files, and it survives `bun build --compile`. Reading from a directory has to cope with `import.meta.dir` pointing at `B:\~BUN\root` after packing — the `PACKED` branch in `src/host.ts` exists precisely for that. Five lines in a `routes` callback is a better trade than leaking that problem into the skeleton.
|
|
91
|
+
|
|
92
|
+
- `decideWs()` — the `/ws` decision, extracted as a pure function because it is the only branch in the skeleton and it has three ways to be wrong. `/ws` with IPC disabled is a **404, not a 403**: a 403 advertises that a channel exists. The token check must happen **before** the upgrade — after upgrading there is no chance to refuse. And the path comparison must come first, or a caller's catch-all route (the usual SPA fallback) swallows the upgrade request, leaving a page that can never connect while HTTP looks perfectly healthy.
|
|
93
|
+
|
|
94
|
+
### Changed
|
|
95
|
+
|
|
96
|
+
- `attachPlacementStore` is now event-driven. It used to call `getBounds()` every 700 ms; it now takes a snapshot the moment a `STATE` event arrives and batches the disk write until 300 ms after the window settles. A window at rest costs zero native calls and zero disk writes, and a drag no longer writes on every frame. The `intervalMs` option is replaced by `debounceMs` (write delay) and `pumpMs` (internal pump cadence) — a 0.x minor is allowed to break that, and the old name would now be a lie.
|
|
97
|
+
|
|
98
|
+
Snapshot and write are deliberately separate. Once the window is destroyed, `format_bounds()` returns an empty string because `!g_hwnd`; reading at debounce time would therefore drop the last change — exactly the one made by "drag, then immediately click X", which is how most people close a window.
|
|
99
|
+
|
|
100
|
+
- `createApp`'s `stop()` and `wait()` wait for `EVENT_CLOSED` instead of polling `isAlive()` every 50 ms. The 5-second ceiling stays: if the event channel ever fails, the host must not hang forever, and the timeout also resolves the close promise so `wait()` cannot hang on a dead channel. `isAlive()` keeps its symbol and its API — it is still a legitimate instant snapshot — but it is no longer the waiting criterion. `app.events` is exposed as the single consumer, so `attachPlacementStore(shell, file, { events: app.events })` reuses it instead of starting a second one.
|
|
101
|
+
|
|
102
|
+
- Exported symbols 33 → 34 and TypeScript declarations 32 → 33 (`shell_poll_events`). `native-surface.test.ts`'s hand-written `DECLARED` list and its total-count assertion moved with it.
|
|
103
|
+
|
|
104
|
+
- Twelve assertions in `tests/app.test.ts` plus seven in `scripts/consumer-probe.ts`. The probe now also exercises `bun-app-window/app` from the installed package.
|
|
105
|
+
- Three hardcoded counts in the docs are gone (`40 assertions`, `8 subpaths`, `182 test cases`): all three had already drifted. The consumer probe reports its own count now, and `AGENTS.md` says so.
|
|
106
|
+
|
|
107
|
+
### Note
|
|
108
|
+
|
|
109
|
+
- The pump timer's `unref` is not an implementation detail. An unref'd timer does not hold the event loop open, and a promise that never resolves does not either — so a host that `await`s for close while relying on an unref'd pump will exit with the window still open, which looks like "the app flashes and quits". `./state` unrefs (a persistence timer should not keep a process alive); `./app` pins the timer with `keepAlive: true` because waiting for close is exactly what it needs to survive.
|
|
110
|
+
|
|
111
|
+
- The acceptance for this release was bidirectional rather than one-sided. Commenting out `emit_event(SHELL_EVENT_STATE, ...)`, rebuilding, and running `scripts/probe-window-events.ts` turned it from 21/21 green to 11/21 red with `exit=1` — while the ten `CLOSED` assertions stayed green, confirming the injection was isolated to the state path. Restoring and rebuilding returned 21/21, and `verify-dll.ts` reported the same sha256 as before the injection, which also proves the build is reproducible. `docs/pitfalls.md` 2.4k records the four failure modes found along the way.
|
|
112
|
+
|
|
113
|
+
- This is **not** a competitor to `Bun.WebView`, and the READMEs now say so explicitly. `Bun.WebView` is a *headless* browser for automation, testing and scraping: it has no window, and it can only screenshot its own page. This package draws a real desktop window — system title bar, DPI awareness, snap layouts, taskbar identity — and its capture tool can photograph **any** window, which is the whole point for computer-use. Comparing API surface between the two is comparing different problems.
|
|
8
114
|
|
|
9
115
|
## [0.3.0] - 2026-10-21
|
|
10
116
|
|
|
@@ -221,7 +327,9 @@ First public release.
|
|
|
221
327
|
- Not yet verified in managed environments that enforce AppLocker, WDAC, or third-party EDR behaviour blocking.
|
|
222
328
|
- No commercially trusted code signing: only a self-signed certificate has been exercised end to end.
|
|
223
329
|
|
|
224
|
-
[Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.
|
|
330
|
+
[Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.1...HEAD
|
|
331
|
+
[0.4.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.0...v0.4.1
|
|
332
|
+
[0.4.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.3.0...v0.4.0
|
|
225
333
|
[0.3.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.2...v0.3.0
|
|
226
334
|
[0.2.2]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.1...v0.2.2
|
|
227
335
|
[0.2.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.0...v0.2.1
|
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ bun add bun-app-window
|
|
|
46
46
|
|
|
47
47
|
### What the tarball contains
|
|
48
48
|
|
|
49
|
-
The published package holds the prebuilt `dist/libshell.dll` plus the TypeScript sources —
|
|
49
|
+
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`.
|
|
50
50
|
|
|
51
51
|
## Quick Start
|
|
52
52
|
|
|
@@ -84,7 +84,8 @@ Note: `createShell(opts)` only resolves and loads the native DLL without opening
|
|
|
84
84
|
| `start()` | Opens the window and returns `{ rc, error }`. Returns immediately while the UI message loop runs on a dedicated native thread |
|
|
85
85
|
| `stop()` | Destroys the window and releases native resources. Safe to call multiple times (idempotent) |
|
|
86
86
|
| `show()` | Reveals the window. Typically paired with `FLAG_DEFER_SHOW` to display the window only after the web UI is ready |
|
|
87
|
-
| `isAlive()` | Checks whether the native window still exists (unlike `state()`, which cannot distinguish between unstarted and destroyed states) |
|
|
87
|
+
| `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 |
|
|
88
|
+
| `pollEvents(max?)` | Drains window lifecycle events (`STATE` / `CLOSED`); synchronous and consuming. See "Window events" below |
|
|
88
89
|
| `state()` | Returns current window state: `{ maximized, minimized, focused, dark, raw }` |
|
|
89
90
|
| `lastError()` | Returns the most recent failure message, or an empty string if no error occurred |
|
|
90
91
|
| `setTitle` / `minimize` / `toggleMaximize` / `close` | Standard window control operations; each returns a status code `rc` |
|
|
@@ -138,6 +139,88 @@ the default picks the safe side.
|
|
|
138
139
|
|
|
139
140
|
Additional capabilities outside core window management are modularized into dedicated subpath exports.
|
|
140
141
|
|
|
142
|
+
### `bun-app-window/app` — Application Skeleton
|
|
143
|
+
|
|
144
|
+
The first app you write with this package is about 30 lines of business code and **180 lines of
|
|
145
|
+
host boilerplate**: picking a safe port, minting an auth token, handling the WebSocket upgrade,
|
|
146
|
+
serving the HTML as a string, wiring `createIpcServer` into Bun's websocket callbacks, composing
|
|
147
|
+
window flags, handling `openShell` failure, waiting for close (`EVENT_CLOSED`), and calling
|
|
148
|
+
`stop()` on two things in the right order. Each piece is easy; they are only correct in a specific
|
|
149
|
+
order (`stop()` must wait for the window to actually die before closing the server, or the next
|
|
150
|
+
start hits "already started"). `app` collapses all of it into one call:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// main.ts
|
|
154
|
+
import { createApp } from "bun-app-window/app";
|
|
155
|
+
import { BROWSER_UI_ALL, FLAG_FRAMELESS } from "bun-app-window";
|
|
156
|
+
import htmlText from "./index.html" with { type: "text" };
|
|
157
|
+
|
|
158
|
+
// `with { type: "text" }` inlines the file at build time, so it survives `bun build --compile`.
|
|
159
|
+
// TS 7.0.2 cannot see that declaration — bun-types gates it behind `typesVersions >= 7.1`,
|
|
160
|
+
// so the import resolves to `HTMLBundle` instead of `string`. Hence the bridge.
|
|
161
|
+
const html = htmlText as unknown as string;
|
|
162
|
+
|
|
163
|
+
const app = await createApp({
|
|
164
|
+
html,
|
|
165
|
+
title: "My App",
|
|
166
|
+
flags: FLAG_FRAMELESS, // opt in to a self-drawn title bar
|
|
167
|
+
browserUi: BROWSER_UI_ALL,
|
|
168
|
+
service: {
|
|
169
|
+
greet: (name: string) => `hi ${name}`,
|
|
170
|
+
pickFolder: () => app.shell.pickFolder(),
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await app.wait(); // resolves when the window closes
|
|
175
|
+
await app.stop(); // releases the event pump and the server; idempotent
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
// page.ts — inside index.html's bundle
|
|
180
|
+
import { connect } from "bun-app-window/ipc";
|
|
181
|
+
const api = await connect(); // token comes from the page URL's ?t=
|
|
182
|
+
await api.greet("Ada");
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
What it does for you:
|
|
186
|
+
|
|
187
|
+
- Binds a browser-safe port (never one that yields `ERR_UNSAFE_PORT`) and serves your HTML.
|
|
188
|
+
- Generates a per-run token and guards `/ws` with it **before** the upgrade — after upgrading there
|
|
189
|
+
is no chance to refuse. `/ws` with IPC disabled is a `404`, not a `403`: a 403 would advertise
|
|
190
|
+
that a channel exists.
|
|
191
|
+
- Wires `createIpcServer` into the websocket `open` / `message` / `close` callbacks so
|
|
192
|
+
`ipc.emit()` reaches the page.
|
|
193
|
+
- `stop()` is ordered and idempotent: `shell.stop()` → wait for `EVENT_CLOSED` (5 s ceiling) →
|
|
194
|
+
`server.stop()`.
|
|
195
|
+
- Starts exactly one window-event pump: `app.events`. It is also what waits for close, so it is
|
|
196
|
+
the single consumer. Pass `{ events: app.events }` when you need `attachPlacementStore`.
|
|
197
|
+
- Startup failures leave nothing half-started: if `openShell` throws, the bound port is released.
|
|
198
|
+
|
|
199
|
+
Notes:
|
|
200
|
+
|
|
201
|
+
- **Does not default to `FLAG_FRAMELESS`.** A frameless window has no system title bar, so it cannot
|
|
202
|
+
be dragged until the page mounts `mountTitleBar`. The default is the side that works even when the
|
|
203
|
+
page does nothing.
|
|
204
|
+
- **`wait()` only waits.** It resolves on `EVENT_CLOSED` but does not stop the event pump or the
|
|
205
|
+
server — and the pump timer is ref'd (`keepAlive: true`), so a process that stops at `wait()`
|
|
206
|
+
keeps running after the window is gone. Always follow it with `stop()`: it is idempotent and
|
|
207
|
+
returns immediately once the close has already been observed.
|
|
208
|
+
- **Static assets are inline strings only.** `with { type: "text" }` yields a compile-time constant:
|
|
209
|
+
nothing touches the filesystem, no temp files, and it survives `bun build --compile`. TS 7.0.2
|
|
210
|
+
resolves that import to `HTMLBundle` rather than `string` — `bun-types` gates the correct
|
|
211
|
+
declaration behind `typesVersions >= 7.1` — which is what the `as unknown as string` in the
|
|
212
|
+
example is for. Do not "fix" it with `Bun.file().text()`: that reads at run time, and the HTML
|
|
213
|
+
would no longer travel inside the compiled executable. For anything read from disk, write a
|
|
214
|
+
`routes` function — five lines, and the path-resolution problem stays yours instead of leaking
|
|
215
|
+
into the skeleton.
|
|
216
|
+
- **Not re-exported from the main entry.** It is a subpath like `./state` and `./ipc`. It imports
|
|
217
|
+
`openShell` from `./index`; exporting it from `index.ts` would make that a cycle.
|
|
218
|
+
|
|
219
|
+
This is **not** a replacement for `Bun.WebView`. That one is a *headless* browser for automation,
|
|
220
|
+
testing and scraping — it has no window, and it can only screenshot its own page. This package
|
|
221
|
+
draws a real desktop window with a system title bar, DPI awareness, snap layouts and taskbar
|
|
222
|
+
identity. The two do not compete.
|
|
223
|
+
|
|
141
224
|
### `bun-app-window/ipc` — Typed RPC & Event Bus
|
|
142
225
|
|
|
143
226
|
Share a single TypeScript contract between the host and the web page for compile-time verification of method signatures and event payloads:
|
|
@@ -180,39 +263,59 @@ api.on("windowState", (s) => bar.setMaximized(s.maximized));
|
|
|
180
263
|
|
|
181
264
|
By default, `connect()` extracts the auth token from the page URL's `?t=` query parameter and connects to `/ws`. Pass a custom `url` or `channel` option to override this behavior.
|
|
182
265
|
|
|
266
|
+
#### Reserved client names
|
|
267
|
+
|
|
268
|
+
The client is a `Proxy`: a handful of names resolve to the client itself and never reach a remote
|
|
269
|
+
method. If your service exposes a method under one of these names, **the server-side method becomes
|
|
270
|
+
unreachable** — the page calls the client's own member instead, and neither end reports an error.
|
|
271
|
+
|
|
272
|
+
| Name | What the page actually gets |
|
|
273
|
+
| --- | --- |
|
|
274
|
+
| `on` / `off` | Event subscription helpers |
|
|
275
|
+
| `close()` | Closes the WebSocket channel — every later call is rejected |
|
|
276
|
+
| `connected` | `boolean`: whether the channel is open |
|
|
277
|
+
| `pending` | `number` of calls still awaiting a reply |
|
|
278
|
+
| `then` | `undefined`, so `await client` cannot hang on a never-resolving thenable |
|
|
279
|
+
|
|
280
|
+
Symbol keys are reserved as well. Pick a different service name (`winClose` instead of `close`) and
|
|
281
|
+
map it back on the page side — see the title bar section below.
|
|
282
|
+
|
|
183
283
|
### `bun-app-window/titlebar` — Windows 11 Title Bar
|
|
184
284
|
|
|
185
|
-
Mounts a customizable Windows 11 title bar inside frameless windows
|
|
285
|
+
Mounts a customizable Windows 11 title bar inside frameless windows. The IPC service is **flat** —
|
|
286
|
+
there is no `window` namespace — and one of the four natural names is taken by the client itself,
|
|
287
|
+
so the host exposes `winClose` and the page maps it back:
|
|
186
288
|
|
|
187
289
|
```ts
|
|
188
|
-
//
|
|
290
|
+
// Host — flat service; `close` is reserved by the IPC client
|
|
291
|
+
createIpcServer({
|
|
292
|
+
service: {
|
|
293
|
+
minimize: () => shell.minimize(),
|
|
294
|
+
toggleMaximize: () => shell.toggleMaximize(),
|
|
295
|
+
winClose: () => shell.close(),
|
|
296
|
+
drag: () => shell.drag(),
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
// Page — build the bridge explicitly
|
|
189
303
|
import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
|
|
190
304
|
|
|
191
305
|
const bar = mountTitleBar({
|
|
192
306
|
title: "My App",
|
|
193
307
|
icon: "/icon.svg",
|
|
194
|
-
bridge: bridgeFromWindowService(
|
|
308
|
+
bridge: bridgeFromWindowService({
|
|
309
|
+
minimize: () => api.minimize(),
|
|
310
|
+
toggleMaximize: () => api.toggleMaximize(),
|
|
311
|
+
close: () => api.winClose(), // mapped back to the bridge's own name
|
|
312
|
+
drag: () => api.drag(),
|
|
313
|
+
}),
|
|
195
314
|
});
|
|
196
315
|
|
|
197
316
|
api.on("windowState", (s) => bar.setMaximized(s.maximized));
|
|
198
317
|
```
|
|
199
318
|
|
|
200
|
-
Expose the four corresponding window control methods on the host IPC service:
|
|
201
|
-
|
|
202
|
-
```ts
|
|
203
|
-
// Host
|
|
204
|
-
createIpcServer({
|
|
205
|
-
service: {
|
|
206
|
-
window: {
|
|
207
|
-
minimize: () => shell.minimize(),
|
|
208
|
-
toggleMaximize: () => shell.toggleMaximize(),
|
|
209
|
-
close: () => shell.close(),
|
|
210
|
-
drag: () => shell.drag(),
|
|
211
|
-
},
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
```
|
|
215
|
-
|
|
216
319
|
Implementation notes:
|
|
217
320
|
|
|
218
321
|
- Native dragging: Calling `shell.drag()` dispatches a native `WM_NCLBUTTONDOWN` (`HTCAPTION`) message, preserving Windows 11 Snap Layouts and double-click maximize behavior.
|
|
@@ -233,7 +336,7 @@ if (saved) restorePlacement(shell, saved);
|
|
|
233
336
|
|
|
234
337
|
shell.show();
|
|
235
338
|
|
|
236
|
-
const store = attachPlacementStore(shell, FILE); //
|
|
339
|
+
const store = attachPlacementStore(shell, FILE); // Subscribes to window events; writes once the drag settles
|
|
237
340
|
process.on("beforeExit", () => store.flush());
|
|
238
341
|
```
|
|
239
342
|
|
|
@@ -243,7 +346,60 @@ The native placement layer handles edge cases automatically:
|
|
|
243
346
|
- Translates coordinates across monitors with different DPI scaling factors.
|
|
244
347
|
- Validates and clamps off-screen coordinates when monitor topology changes.
|
|
245
348
|
|
|
246
|
-
|
|
349
|
+
Writes are event-driven: a state change takes a snapshot **immediately** (a microsecond-scale
|
|
350
|
+
read), and the disk write is batched 300 ms after the window settles. A window at rest costs
|
|
351
|
+
neither a native call nor a disk write. Tune the write delay with `debounceMs`; tune the
|
|
352
|
+
internal event pump's cadence with `pumpMs`.
|
|
353
|
+
|
|
354
|
+
If you already have an event pump (for example from `createApp`), pass it in so two consumers
|
|
355
|
+
do not race for the same events:
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
import { createApp } from "bun-app-window/app";
|
|
359
|
+
import { attachPlacementStore } from "bun-app-window/state";
|
|
360
|
+
|
|
361
|
+
const app = await createApp({ html });
|
|
362
|
+
const store = attachPlacementStore(app.shell, FILE, { events: app.events });
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
For one-off reads and writes without any subscription, use `capturePlacement` and
|
|
366
|
+
`savePlacement` directly.
|
|
367
|
+
|
|
368
|
+
### Window events
|
|
369
|
+
|
|
370
|
+
The host does not have to poll to learn that the window changed — `pollEvents()` returns the
|
|
371
|
+
event stream directly:
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
import { EVENT_CLOSED, EVENT_STATE } from "bun-app-window/events";
|
|
375
|
+
|
|
376
|
+
for (const e of shell.pollEvents()) {
|
|
377
|
+
if (e.type === EVENT_CLOSED) break; // window is gone, the native singleton is released
|
|
378
|
+
if (e.type === EVENT_STATE && e.maximized) { /* ... */ }
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`pollEvents()` is **consuming**: drained records never come back, so a process should have
|
|
383
|
+
exactly one consumer. For a fixed cadence plus multi-subscriber fan-out, use
|
|
384
|
+
`attachEventPump()` from `./events`:
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
import { EVENT_CLOSED, attachEventPump } from "bun-app-window/events";
|
|
388
|
+
|
|
389
|
+
const pump = attachEventPump(shell, { intervalMs: 16 });
|
|
390
|
+
pump.on((e) => { if (e.type === EVENT_CLOSED) pump.stop(); });
|
|
391
|
+
pump.onGap((missing) => console.warn(`missed ${missing} events`));
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
- A `STATE` payload is a **snapshot**: `width` / `height` / `dpi` plus four flags
|
|
395
|
+
(`maximized` / `minimized` / `focused` / `dark`).
|
|
396
|
+
- `CLOSED` is terminal. It is emitted by the native layer after teardown has fully completed
|
|
397
|
+
and the window handle is gone, so "seeing `CLOSED`" is equivalent to "safe to restart the
|
|
398
|
+
window" — that is what makes it stronger than `isAlive()`.
|
|
399
|
+
- The native ring buffer holds 128 records and drops the oldest when full. Losses are reported
|
|
400
|
+
faithfully as gaps in `seq` (via `onGap`), never disguised as "nothing happened".
|
|
401
|
+
- The pump timer is `unref`'d by default (it will not keep the process alive). Pass
|
|
402
|
+
`keepAlive: true` when you rely on it to wait for window close.
|
|
247
403
|
|
|
248
404
|
### `bun-app-window/debug` — Diagnostics
|
|
249
405
|
|
|
@@ -253,11 +409,14 @@ Exposes internal metrics and hit-testing utilities. Note: This module is exclude
|
|
|
253
409
|
import { debugOf } from "bun-app-window/debug";
|
|
254
410
|
|
|
255
411
|
const d = debugOf(shell);
|
|
256
|
-
d.metrics(); //
|
|
257
|
-
d.dpi(); //
|
|
412
|
+
d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
|
|
413
|
+
d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
|
|
258
414
|
d.hittest(x, y);
|
|
259
415
|
```
|
|
260
416
|
|
|
417
|
+
`metrics()` and `dpi()` return **formatted text**, not objects. Parse it in throwaway tooling if you
|
|
418
|
+
must; do not put a parser in production code, because the layout is not part of any contract.
|
|
419
|
+
|
|
261
420
|
### `bun-app-window/native` — The Prebuilt DLL
|
|
262
421
|
|
|
263
422
|
Resolves to `dist/libshell.dll` itself, so `import.meta.resolve()` hands you a real path you can copy next to your executable:
|
|
@@ -299,13 +458,10 @@ Note: `F11`, `F12`, and `Ctrl±` zoom shortcuts are handled directly by the nati
|
|
|
299
458
|
After disabling the default browser menu, render native Win32 popup menus (`TrackPopupMenu`) via `showContextMenu`. The page cannot call it directly — expose it through the IPC service first:
|
|
300
459
|
|
|
301
460
|
```ts
|
|
302
|
-
// Host: expose the menu capability on the
|
|
461
|
+
// Host: expose the menu capability on the flat service
|
|
303
462
|
createIpcServer({
|
|
304
463
|
service: {
|
|
305
|
-
|
|
306
|
-
showContextMenu: (x: number, y: number, items: string) =>
|
|
307
|
-
shell.showContextMenu(x, y, items),
|
|
308
|
-
},
|
|
464
|
+
showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
|
|
309
465
|
},
|
|
310
466
|
});
|
|
311
467
|
```
|
|
@@ -314,7 +470,7 @@ createIpcServer({
|
|
|
314
470
|
// Page: trigger the native menu
|
|
315
471
|
document.addEventListener("contextmenu", async (e) => {
|
|
316
472
|
e.preventDefault();
|
|
317
|
-
await api.
|
|
473
|
+
await api.showContextMenu(
|
|
318
474
|
Math.round(e.screenX),
|
|
319
475
|
Math.round(e.screenY),
|
|
320
476
|
"open|Open\nrefresh|Refresh\n-\nquit|Quit"
|
|
@@ -386,6 +542,19 @@ Tips:
|
|
|
386
542
|
- A side-effect import `import "bun-app-window/embedded";` also works automatically.
|
|
387
543
|
- Always pass `--windows-hide-console` for GUI builds; omitting it will spawn a black command prompt window alongside your application.
|
|
388
544
|
|
|
545
|
+
### 6. Two Traps That Type-Check
|
|
546
|
+
|
|
547
|
+
Neither of these fails `bun check`; both produce a window that renders and then does nothing.
|
|
548
|
+
|
|
549
|
+
- **The page bundle is an ES module, so the `<script>` tag needs `type="module"`.** A build with a
|
|
550
|
+
top-level `await` is a syntax error inside a classic `<script>`, and the **whole script never
|
|
551
|
+
runs** — while the page still renders, because the markup is static. Nothing on screen points at
|
|
552
|
+
the cause.
|
|
553
|
+
- **Namespace your own `data-*` selectors.** The title bar renders buttons with
|
|
554
|
+
`data-act="minimize|maximize|close"` and listens for clicks on `document`. A host that also binds
|
|
555
|
+
`button[data-act]` receives the same click twice and logs its own "unknown action: maximize".
|
|
556
|
+
Prefix your attributes (`data-mvp-act`): a generic name is a shared namespace, not yours.
|
|
557
|
+
|
|
389
558
|
## Internals & Reference
|
|
390
559
|
|
|
391
560
|
### Native Library Resolution Order
|
|
@@ -421,7 +590,7 @@ bun run scripts/gate.ts --clean # same gates, but inside a fresh clone of H
|
|
|
421
590
|
|
|
422
591
|
`-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.
|
|
423
592
|
|
|
424
|
-
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 —
|
|
593
|
+
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.
|
|
425
594
|
|
|
426
595
|
The underlying commands remain available directly:
|
|
427
596
|
|