bun-app-window 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -2
- package/README.md +147 -5
- package/README.zh-CN.md +130 -5
- package/README.zh-TW.md +131 -5
- 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/shell/types.ts +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,97 @@ 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.0] - 2026-10-21
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `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".
|
|
12
|
+
|
|
13
|
+
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:
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
native: UI thread appends 32-byte records to a 128-slot ring,
|
|
17
|
+
holding one SRWLOCK for at most a 4 KiB copy
|
|
18
|
+
host: shell_poll_events(buf, capacity) → shell.pollEvents() / attachEventPump()
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Four decisions, each with a failure mode behind it:
|
|
22
|
+
|
|
23
|
+
**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.
|
|
24
|
+
|
|
25
|
+
**`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.
|
|
26
|
+
|
|
27
|
+
**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.
|
|
28
|
+
|
|
29
|
+
**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.
|
|
30
|
+
|
|
31
|
+
- `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.
|
|
32
|
+
|
|
33
|
+
- `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.
|
|
34
|
+
|
|
35
|
+
- `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:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const app = await createApp({ html, service: { greet: (n) => `hi ${n}` } });
|
|
39
|
+
await app.wait();
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Three decisions worth recording:
|
|
43
|
+
|
|
44
|
+
**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.
|
|
45
|
+
|
|
46
|
+
**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.
|
|
47
|
+
|
|
48
|
+
**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.
|
|
49
|
+
|
|
50
|
+
- `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.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- `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.
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
- `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.
|
|
59
|
+
|
|
60
|
+
- 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.
|
|
61
|
+
|
|
62
|
+
- 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.
|
|
63
|
+
- 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.
|
|
64
|
+
|
|
65
|
+
### Note
|
|
66
|
+
|
|
67
|
+
- 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.
|
|
68
|
+
|
|
69
|
+
- 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.
|
|
70
|
+
|
|
71
|
+
- 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.
|
|
72
|
+
|
|
73
|
+
## [0.3.0] - 2026-10-21
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
- **A page link could navigate the app away, permanently.** In a desktop shell there is no address bar, no back button, and no host API that can navigate the page back — so one mis-click on an ordinary `<a href="https://example.com">` replaced the application with that website, and the only recovery was to close and relaunch. The native layer could not stop it either: `NavigationStarting` was never subscribed, `NewWindowRequested` was never subscribed, and `g_origin` was never assigned.
|
|
78
|
+
|
|
79
|
+
This is worth describing precisely, because the failure was not a wrong decision — it was **241 lines of correct-looking code that had never been compiled**. `NavigationGuard`, `NewWindowGuard`, `open_external()`, `install_nav_guard()` and `uninstall_nav_guard()` were all present and heavily commented, but: `install_nav_guard()` had **no call site anywhere in the tree**; `SHELL_FLAG_ALLOW_EXTERNAL_NAV = 128` was declared in the C++ enum with **no parsing site**, and the TypeScript side had no corresponding constant at all, so a caller could not even express the intent; and `g_origin` carried a comment saying it was "computed by `shell_start_ex` from `g_url`" while never being assigned — which alone would have made `install_nav_guard()`'s first guard clause return early forever. Type checking, 532 test cases and the full gate were green throughout. The first `bun run build:native` produced six compile errors and settled it: `NavEventBase` existed to hold the refcount and `QueryInterface` plumbing, and **neither guard inherited it**.
|
|
80
|
+
|
|
81
|
+
Two independent guards now install before `webview_navigate` and uninstall before `webview_destroy` (after destroy, `core_webview()` returns `nullptr` and `remove_` is unreachable). Verified end-to-end on a real window: a cross-origin navigation is cancelled and handed to the system browser — which then actually loaded the URL — while a same-origin navigation in the same run went through untouched.
|
|
82
|
+
|
|
83
|
+
### Changed
|
|
84
|
+
|
|
85
|
+
- **`FLAG_ALLOW_EXTERNAL_NAV` (new, `128`) opts out of navigation interception** for applications that genuinely are browsers. This is the release's one breaking-in-effect change: without the flag, a cross-origin top-level navigation now opens the system browser instead of replacing the app. Nothing else about the flag's surrounding policy moved — `window.open` / `target=_blank` is still cancelled and opened externally **even with this flag**, because that is a capability boundary rather than a safety policy: the shell owns exactly one top-level window (the native side is a process-level singleton), so a second window would have no host API able to close, read or message it.
|
|
86
|
+
- Only `http:` / `https:` ever reach `ShellExecuteW`. `file:`, `javascript:`, `ms-*:` and custom schemes are cancelled silently and never touch a protocol handler, and `mailto:` / `tel:` are deliberately **not** on the list either — a dead `mailto:` link is preferable to handing a page an arbitrary protocol dispatcher.
|
|
87
|
+
- **An origin-less startup URL is no longer a free pass.** The old predicate returned "same origin" whenever `g_origin` was empty, which meant `file://`, `about:blank` and custom-scheme apps — the most common shape for a desktop shell — were the least protected. That branch now degrades to a *same-document* test: a navigation stays inside the window only if it matches the startup URL after stripping the `#fragment`. In-app hash routing keeps working; anything else is external.
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
|
|
91
|
+
- `tests/nav-guard.test.ts` — eleven assertions, written against the **call-site and assignment-site** shapes rather than identifier presence, because `toContain("install_nav_guard")` passes on the definition alone and the definition was never the problem. It also pins the two `FLAG_*` tables together in both directions: every type constant must exist in the C++ enum **and** every C++ flag constant must exist in the type table. The forward assertion alone would not have caught this release's failure — the C++ side was written first and the TypeScript side was simply missing.
|
|
92
|
+
- `docs/pitfalls.md` 2.11 (declared-but-not-wired) and 2.12 (a probe's own criterion can lie), the latter recording two false criteria written during this work: a placeholder expression dressed as an assertion, and a "did the system browser load the page" check that cannot distinguish the browser from an in-webview navigation because both hit the same endpoint.
|
|
93
|
+
|
|
94
|
+
### Note
|
|
95
|
+
|
|
96
|
+
- The published tarball gains one exported constant (`FLAG_ALLOW_EXTERNAL_NAV`) and one `dist/libshell.dll` rebuild. Native exports are unchanged at 33 symbols.
|
|
97
|
+
- The behaviour change is observable without a flag: an app that was silently relying on external links navigating its own window will now see them open in the browser instead. That is the intent, and it is why this is a minor rather than a patch.
|
|
8
98
|
|
|
9
99
|
## [0.2.2] - 2026-10-21
|
|
10
100
|
|
|
@@ -195,7 +285,9 @@ First public release.
|
|
|
195
285
|
- Not yet verified in managed environments that enforce AppLocker, WDAC, or third-party EDR behaviour blocking.
|
|
196
286
|
- No commercially trusted code signing: only a self-signed certificate has been exercised end to end.
|
|
197
287
|
|
|
198
|
-
[Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.
|
|
288
|
+
[Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.0...HEAD
|
|
289
|
+
[0.4.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.3.0...v0.4.0
|
|
290
|
+
[0.3.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.2...v0.3.0
|
|
199
291
|
[0.2.2]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.1...v0.2.2
|
|
200
292
|
[0.2.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.0...v0.2.1
|
|
201
293
|
[0.2.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.1.0...v0.2.0
|
package/README.md
CHANGED
|
@@ -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` |
|
|
@@ -113,11 +114,99 @@ Combine via bitwise OR (`|`) and pass to `opts.flags`:
|
|
|
113
114
|
| `FLAG_DEFER_SHOW` | Creates the window hidden initially until `show()` is explicitly called |
|
|
114
115
|
| `FLAG_ACCEPT_DROP` | Enables native file and folder drag-and-drop from Windows Explorer |
|
|
115
116
|
| `FLAG_START_MAXIMIZED` | Opens the window directly in a maximized state without an intermediate normal-sized frame flash |
|
|
117
|
+
| `FLAG_ALLOW_EXTERNAL_NAV` | Opts out of external-navigation interception — for apps that genuinely are browsers |
|
|
118
|
+
|
|
119
|
+
### External navigation is blocked by default
|
|
120
|
+
|
|
121
|
+
Without `FLAG_ALLOW_EXTERNAL_NAV`, a cross-origin link can no longer navigate the app away.
|
|
122
|
+
The shell cancels the navigation and hands the URL to the system browser instead:
|
|
123
|
+
|
|
124
|
+
- **Same origin** (or, for `file://` / other origin-less startup URLs, the same document
|
|
125
|
+
ignoring the `#fragment`) stays inside the window. In-app routing keeps working.
|
|
126
|
+
- **Cross origin** is cancelled and opened externally — but only for `http:` / `https:`.
|
|
127
|
+
`file:`, `javascript:`, `ms-*:` and custom schemes are cancelled silently and never
|
|
128
|
+
reach a protocol handler. `mailto:` / `tel:` are deliberately not allowed.
|
|
129
|
+
- **`window.open` / `target=_blank`** is always cancelled and opened externally, even with
|
|
130
|
+
`FLAG_ALLOW_EXTERNAL_NAV`. That is a capability boundary, not a policy: the shell owns a
|
|
131
|
+
single top-level window (native side is a process-level singleton), so a second window
|
|
132
|
+
would have no host API to manage it.
|
|
133
|
+
|
|
134
|
+
A desktop shell has no address bar and no back button, and no host API can navigate the page
|
|
135
|
+
back — so a single mis-click would otherwise replace the app with that website. That is why
|
|
136
|
+
the default picks the safe side.
|
|
116
137
|
|
|
117
138
|
## Subpath Exports
|
|
118
139
|
|
|
119
140
|
Additional capabilities outside core window management are modularized into dedicated subpath exports.
|
|
120
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 html from "./index.html" with { type: "text" };
|
|
157
|
+
|
|
158
|
+
const app = await createApp({
|
|
159
|
+
html,
|
|
160
|
+
title: "My App",
|
|
161
|
+
flags: FLAG_FRAMELESS, // opt in to a self-drawn title bar
|
|
162
|
+
browserUi: BROWSER_UI_ALL,
|
|
163
|
+
service: {
|
|
164
|
+
greet: (name: string) => `hi ${name}`,
|
|
165
|
+
pickFolder: () => app.shell.pickFolder(),
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await app.wait(); // resolves when the window closes
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// page.ts — inside index.html's bundle
|
|
174
|
+
import { connect } from "bun-app-window/ipc";
|
|
175
|
+
const api = await connect(); // token comes from the page URL's ?t=
|
|
176
|
+
await api.greet("Ada");
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
What it does for you:
|
|
180
|
+
|
|
181
|
+
- Binds a browser-safe port (never one that yields `ERR_UNSAFE_PORT`) and serves your HTML.
|
|
182
|
+
- Generates a per-run token and guards `/ws` with it **before** the upgrade — after upgrading there
|
|
183
|
+
is no chance to refuse. `/ws` with IPC disabled is a `404`, not a `403`: a 403 would advertise
|
|
184
|
+
that a channel exists.
|
|
185
|
+
- Wires `createIpcServer` into the websocket `open` / `message` / `close` callbacks so
|
|
186
|
+
`ipc.emit()` reaches the page.
|
|
187
|
+
- `stop()` is ordered and idempotent: `shell.stop()` → wait for `EVENT_CLOSED` (5 s ceiling) →
|
|
188
|
+
`server.stop()`.
|
|
189
|
+
- Starts exactly one window-event pump: `app.events`. It is also what waits for close, so it is
|
|
190
|
+
the single consumer. Pass `{ events: app.events }` when you need `attachPlacementStore`.
|
|
191
|
+
- Startup failures leave nothing half-started: if `openShell` throws, the bound port is released.
|
|
192
|
+
|
|
193
|
+
Notes:
|
|
194
|
+
|
|
195
|
+
- **Does not default to `FLAG_FRAMELESS`.** A frameless window has no system title bar, so it cannot
|
|
196
|
+
be dragged until the page mounts `mountTitleBar`. The default is the side that works even when the
|
|
197
|
+
page does nothing.
|
|
198
|
+
- **Static assets are inline strings only.** `with { type: "text" }` yields a compile-time constant:
|
|
199
|
+
nothing touches the filesystem, no temp files, and it survives `bun build --compile`. For
|
|
200
|
+
anything read from disk, write a `routes` function — five lines, and the path-resolution problem
|
|
201
|
+
stays yours instead of leaking into the skeleton.
|
|
202
|
+
- **Not re-exported from the main entry.** It is a subpath like `./state` and `./ipc`. It imports
|
|
203
|
+
`openShell` from `./index`; exporting it from `index.ts` would make that a cycle.
|
|
204
|
+
|
|
205
|
+
This is **not** a replacement for `Bun.WebView`. That one is a *headless* browser for automation,
|
|
206
|
+
testing and scraping — it has no window, and it can only screenshot its own page. This package
|
|
207
|
+
draws a real desktop window with a system title bar, DPI awareness, snap layouts and taskbar
|
|
208
|
+
identity. The two do not compete.
|
|
209
|
+
|
|
121
210
|
### `bun-app-window/ipc` — Typed RPC & Event Bus
|
|
122
211
|
|
|
123
212
|
Share a single TypeScript contract between the host and the web page for compile-time verification of method signatures and event payloads:
|
|
@@ -213,7 +302,7 @@ if (saved) restorePlacement(shell, saved);
|
|
|
213
302
|
|
|
214
303
|
shell.show();
|
|
215
304
|
|
|
216
|
-
const store = attachPlacementStore(shell, FILE); //
|
|
305
|
+
const store = attachPlacementStore(shell, FILE); // Subscribes to window events; writes once the drag settles
|
|
217
306
|
process.on("beforeExit", () => store.flush());
|
|
218
307
|
```
|
|
219
308
|
|
|
@@ -223,7 +312,60 @@ The native placement layer handles edge cases automatically:
|
|
|
223
312
|
- Translates coordinates across monitors with different DPI scaling factors.
|
|
224
313
|
- Validates and clamps off-screen coordinates when monitor topology changes.
|
|
225
314
|
|
|
226
|
-
|
|
315
|
+
Writes are event-driven: a state change takes a snapshot **immediately** (a microsecond-scale
|
|
316
|
+
read), and the disk write is batched 300 ms after the window settles. A window at rest costs
|
|
317
|
+
neither a native call nor a disk write. Tune the write delay with `debounceMs`; tune the
|
|
318
|
+
internal event pump's cadence with `pumpMs`.
|
|
319
|
+
|
|
320
|
+
If you already have an event pump (for example from `createApp`), pass it in so two consumers
|
|
321
|
+
do not race for the same events:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
import { createApp } from "bun-app-window/app";
|
|
325
|
+
import { attachPlacementStore } from "bun-app-window/state";
|
|
326
|
+
|
|
327
|
+
const app = await createApp({ html });
|
|
328
|
+
const store = attachPlacementStore(app.shell, FILE, { events: app.events });
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
For one-off reads and writes without any subscription, use `capturePlacement` and
|
|
332
|
+
`savePlacement` directly.
|
|
333
|
+
|
|
334
|
+
### Window events
|
|
335
|
+
|
|
336
|
+
The host does not have to poll to learn that the window changed — `pollEvents()` returns the
|
|
337
|
+
event stream directly:
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
import { EVENT_CLOSED, EVENT_STATE } from "bun-app-window/events";
|
|
341
|
+
|
|
342
|
+
for (const e of shell.pollEvents()) {
|
|
343
|
+
if (e.type === EVENT_CLOSED) break; // window is gone, the native singleton is released
|
|
344
|
+
if (e.type === EVENT_STATE && e.maximized) { /* ... */ }
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
`pollEvents()` is **consuming**: drained records never come back, so a process should have
|
|
349
|
+
exactly one consumer. For a fixed cadence plus multi-subscriber fan-out, use
|
|
350
|
+
`attachEventPump()` from `./events`:
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
import { EVENT_CLOSED, attachEventPump } from "bun-app-window/events";
|
|
354
|
+
|
|
355
|
+
const pump = attachEventPump(shell, { intervalMs: 16 });
|
|
356
|
+
pump.on((e) => { if (e.type === EVENT_CLOSED) pump.stop(); });
|
|
357
|
+
pump.onGap((missing) => console.warn(`missed ${missing} events`));
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
- A `STATE` payload is a **snapshot**: `width` / `height` / `dpi` plus four flags
|
|
361
|
+
(`maximized` / `minimized` / `focused` / `dark`).
|
|
362
|
+
- `CLOSED` is terminal. It is emitted by the native layer after teardown has fully completed
|
|
363
|
+
and the window handle is gone, so "seeing `CLOSED`" is equivalent to "safe to restart the
|
|
364
|
+
window" — that is what makes it stronger than `isAlive()`.
|
|
365
|
+
- The native ring buffer holds 128 records and drops the oldest when full. Losses are reported
|
|
366
|
+
faithfully as gaps in `seq` (via `onGap`), never disguised as "nothing happened".
|
|
367
|
+
- The pump timer is `unref`'d by default (it will not keep the process alive). Pass
|
|
368
|
+
`keepAlive: true` when you rely on it to wait for window close.
|
|
227
369
|
|
|
228
370
|
### `bun-app-window/debug` — Diagnostics
|
|
229
371
|
|
|
@@ -401,13 +543,13 @@ bun run scripts/gate.ts --clean # same gates, but inside a fresh clone of H
|
|
|
401
543
|
|
|
402
544
|
`-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.
|
|
403
545
|
|
|
404
|
-
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 —
|
|
546
|
+
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.
|
|
405
547
|
|
|
406
548
|
The underlying commands remain available directly:
|
|
407
549
|
|
|
408
550
|
```bash
|
|
409
551
|
bun check # Run TypeScript type checker
|
|
410
|
-
bun test # Run automated test suite (tests
|
|
552
|
+
bun test # Run automated test suite (tests/)
|
|
411
553
|
bun run demo # Launch interactive self-test demo (requires desktop session)
|
|
412
554
|
```
|
|
413
555
|
|
package/README.zh-CN.md
CHANGED
|
@@ -84,7 +84,8 @@ shell.setTitle("Doing work…");
|
|
|
84
84
|
| `start()` | 创建并启动窗口,返回 `{ rc, error }`。调用后立即返回,UI 消息循环运行于独立原生线程 |
|
|
85
85
|
| `stop()` | 销毁窗口并释放底层资源,支持幂等重复调用 |
|
|
86
86
|
| `show()` | 显示窗口。常配合 `FLAG_DEFER_SHOW` 使用,实现在页面渲染就绪后再展现窗口 |
|
|
87
|
-
| `isAlive()` | 检测窗口是否存活(注:`state()`
|
|
87
|
+
| `isAlive()` | 检测窗口是否存活(注:`state()` 无法区分「尚未启动」与「已销毁」状态)。等窗口关闭请用 `pollEvents()` 的 `CLOSED`,别轮询它 |
|
|
88
|
+
| `pollEvents(max?)` | 批量拉取窗口生命周期事件(`STATE` / `CLOSED`),同步、消费式。详见下文「窗口事件」 |
|
|
88
89
|
| `state()` | 获取当前窗口状态:`{ maximized, minimized, focused, dark, raw }` |
|
|
89
90
|
| `lastError()` | 获取最近一次操作失败的错误信息,无错误时返回空字符串 |
|
|
90
91
|
| `setTitle` / `minimize` / `toggleMaximize` / `close` | 基础窗口控制操作,返回状态码 `rc` |
|
|
@@ -113,11 +114,90 @@ shell.setTitle("Doing work…");
|
|
|
113
114
|
| `FLAG_DEFER_SHOW` | 创建窗口后保持隐藏状态,等待显式调用 `show()` |
|
|
114
115
|
| `FLAG_ACCEPT_DROP` | 允许窗口接收外部文件或文件夹拖放事件 |
|
|
115
116
|
| `FLAG_START_MAXIMIZED` | 窗口首次显示即处于最大化状态,避免从普通尺寸跳变到最大化的中间闪烁帧 |
|
|
117
|
+
| `FLAG_ALLOW_EXTERNAL_NAV` | 放弃外部导航拦截——用于本身就是浏览器的应用 |
|
|
118
|
+
|
|
119
|
+
### 外部导航默认被拦截
|
|
120
|
+
|
|
121
|
+
不给 `FLAG_ALLOW_EXTERNAL_NAV` 时,一个跨源链接再也无法把应用导航走。壳会取消这次导航,
|
|
122
|
+
把地址交给系统默认浏览器:
|
|
123
|
+
|
|
124
|
+
- **同源**(启动地址是 `file://` 等无 origin 的形态时,改为判断「同文档」,即剥掉
|
|
125
|
+
`#fragment` 后与启动地址逐字相同)留在窗口内导航,应用自己的路由不受影响。
|
|
126
|
+
- **跨源**会取消导航并交给系统浏览器——但只有 `http:` / `https:`。`file:`、
|
|
127
|
+
`javascript:`、`ms-*:` 以及自定义 scheme 一律静默取消,绝不触达协议处理器;
|
|
128
|
+
`mailto:` / `tel:` 也**不在**放行之列。
|
|
129
|
+
- **`window.open` / `target=_blank`** 始终被拦下并外开,即使给了
|
|
130
|
+
`FLAG_ALLOW_EXTERNAL_NAV` 也一样。那是能力边界而非策略:壳只持有一个顶层窗口
|
|
131
|
+
(原生层是进程级单例),多开出来的窗口没有任何宿主 API 能管它。
|
|
132
|
+
|
|
133
|
+
桌面壳没有地址栏、没有后退按钮,也没有任何宿主 API 能把页面导航回来——所以一次误点
|
|
134
|
+
外链就等于应用被换成了那个网站。这就是默认值选安全一侧的原因。
|
|
116
135
|
|
|
117
136
|
## 子路径模块
|
|
118
137
|
|
|
119
138
|
核心窗口控制之外的扩展能力均拆分在独立子路径下,可按需引入。
|
|
120
139
|
|
|
140
|
+
### `bun-app-window/app` — 应用骨架
|
|
141
|
+
|
|
142
|
+
用这个包写第一个应用时,业务代码可能只有 30 行,但**宿主样板是 180 行**:挑安全端口、
|
|
143
|
+
生成鉴权 token、处理 WebSocket 升级、把 HTML 当字符串吐出去、把 `createIpcServer`
|
|
144
|
+
接到 Bun 的 websocket 回调上、拼窗口 flags、处理 `openShell` 失败、等窗口关闭
|
|
145
|
+
(`EVENT_CLOSED`)、按顺序 `stop()` 两个东西。每一段单看都不难,但它们**只在特定顺序下正确**
|
|
146
|
+
(`stop()` 不先等窗口真的销毁就关服务,下次启动会撞上 "already started")。
|
|
147
|
+
`app` 把这些收成一次调用:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// main.ts
|
|
151
|
+
import { createApp } from "bun-app-window/app";
|
|
152
|
+
import { BROWSER_UI_ALL, FLAG_FRAMELESS } from "bun-app-window";
|
|
153
|
+
import html from "./index.html" with { type: "text" };
|
|
154
|
+
|
|
155
|
+
const app = await createApp({
|
|
156
|
+
html,
|
|
157
|
+
title: "我的应用",
|
|
158
|
+
flags: FLAG_FRAMELESS, // 想要自绘标题栏就显式加
|
|
159
|
+
browserUi: BROWSER_UI_ALL,
|
|
160
|
+
service: {
|
|
161
|
+
greet: (name: string) => `hi ${name}`,
|
|
162
|
+
pickFolder: () => app.shell.pickFolder(),
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await app.wait(); // 窗口关闭时 resolve
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// page.ts —— index.html 的 bundle 里
|
|
171
|
+
import { connect } from "bun-app-window/ipc";
|
|
172
|
+
const api = await connect(); // token 从页面 URL 的 ?t= 取
|
|
173
|
+
await api.greet("Ada");
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
它替你做的事:
|
|
177
|
+
|
|
178
|
+
- 绑定浏览器安全的端口(绝不会是 `ERR_UNSAFE_PORT` 那几个),并把 HTML 服务出去。
|
|
179
|
+
- 每次运行生成一个 token,并在**升级之前**用它校验 `/ws` —— 升级之后就没有机会拒绝了。
|
|
180
|
+
`ipc` 没开时 `/ws` 是 `404` 而不是 `403`:403 等于告诉对方「这里有通道」。
|
|
181
|
+
- 把 `createIpcServer` 接到 websocket 的 `open` / `message` / `close` 上,`ipc.emit()` 才能到页面。
|
|
182
|
+
- `stop()` 有序且幂等:`shell.stop()` → 等 `EVENT_CLOSED`(带 5 秒上限)→ `server.stop()`。
|
|
183
|
+
- 起一个窗口事件泵并且只起一个:`app.events`。它同时承担「等窗口关闭」,
|
|
184
|
+
所以是唯一的事件消费者;`attachPlacementStore` 要用它时传 `{ events: app.events }`。
|
|
185
|
+
- 启动失败不留半启动状态:`openShell` 抛错时,已绑定的端口会被释放。
|
|
186
|
+
|
|
187
|
+
注意事项:
|
|
188
|
+
|
|
189
|
+
- **默认不带 `FLAG_FRAMELESS`。** 无边框窗口没有系统标题栏就拖不动,必须由页面先挂上
|
|
190
|
+
`mountTitleBar`。默认值选的是「页面什么都不做也能用」的那一侧。
|
|
191
|
+
- **静态资源只吃内联字符串。** `with { type: "text" }` 得到的是编译期常量:不碰文件系统、
|
|
192
|
+
不产生临时文件、`bun build --compile` 后仍然成立。要从磁盘读东西就写个 `routes` 函数 ——
|
|
193
|
+
五行,而且路径解析的问题留在你这边,不会渗进骨架。
|
|
194
|
+
- **不从主入口导出。** 它和 `./state`、`./ipc` 一样是子路径。它从 `./index` 导入
|
|
195
|
+
`openShell`;若 `index.ts` 反过来导出它,就成了循环。
|
|
196
|
+
|
|
197
|
+
**这不是 `Bun.WebView` 的替代品。** 那个是 headless 浏览器,做自动化 / 测试 / 抓取 ——
|
|
198
|
+
没有窗口,而且只能截自己的页面。本项目画的是真实桌面窗口:系统标题栏、DPI 感知、
|
|
199
|
+
贴边分屏、任务栏归属。两者不在同一条赛道上。
|
|
200
|
+
|
|
121
201
|
### `bun-app-window/ipc` — 类型化双向通信与事件总线
|
|
122
202
|
|
|
123
203
|
宿主与前端页面共享同一份 TypeScript 类型声明,客户端通过泛型在编译期完成方法签名与事件负载校验:
|
|
@@ -213,7 +293,7 @@ if (saved) restorePlacement(shell, saved);
|
|
|
213
293
|
|
|
214
294
|
shell.show();
|
|
215
295
|
|
|
216
|
-
const store = attachPlacementStore(shell, FILE); //
|
|
296
|
+
const store = attachPlacementStore(shell, FILE); // 订阅窗口事件,拖动停手后写一次盘
|
|
217
297
|
process.on("beforeExit", () => store.flush());
|
|
218
298
|
```
|
|
219
299
|
|
|
@@ -223,7 +303,52 @@ process.on("beforeExit", () => store.flush());
|
|
|
223
303
|
- 跨显示器不同缩放比下的 DPI 坐标换算。
|
|
224
304
|
- 屏幕拓扑变化(如拔除副屏)后的屏外坐标越界校验与修正。
|
|
225
305
|
|
|
226
|
-
|
|
306
|
+
写入是事件驱动的:收到状态变化**立刻**取快照(微秒级),写盘攒到停手 300 ms 后做一次。
|
|
307
|
+
窗口静止时既没有原生调用也没有磁盘写入。用 `debounceMs` 调整写盘延迟;用 `pumpMs`
|
|
308
|
+
调整内部事件泵的拉取节奏。
|
|
309
|
+
|
|
310
|
+
若已有事件泵(例如用了 `createApp`),把它传进来,避免两个消费者争抢同一批事件:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
import { createApp } from "bun-app-window/app";
|
|
314
|
+
import { attachPlacementStore } from "bun-app-window/state";
|
|
315
|
+
|
|
316
|
+
const app = await createApp({ html });
|
|
317
|
+
const store = attachPlacementStore(app.shell, FILE, { events: app.events });
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
若无需后台订阅,也可直接调用 `capturePlacement` 与 `savePlacement` 执行单次手动读写。
|
|
321
|
+
|
|
322
|
+
### 窗口事件
|
|
323
|
+
|
|
324
|
+
宿主要知道「窗口状态变了」,不必轮询——`pollEvents()` 直接给出事件流:
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
import { EVENT_CLOSED, EVENT_STATE } from "bun-app-window/events";
|
|
328
|
+
|
|
329
|
+
for (const e of shell.pollEvents()) {
|
|
330
|
+
if (e.type === EVENT_CLOSED) break; // 窗口已销毁,原生单例已释放
|
|
331
|
+
if (e.type === EVENT_STATE && e.maximized) { /* ... */ }
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`pollEvents()` 是**消费式**的:读走的记录不会再来。所以一个进程只应有一个消费者。
|
|
336
|
+
想要固定节奏与多路订阅时,用 `./events` 的 `attachEventPump()`:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { EVENT_CLOSED, attachEventPump } from "bun-app-window/events";
|
|
340
|
+
|
|
341
|
+
const pump = attachEventPump(shell, { intervalMs: 16 });
|
|
342
|
+
pump.on((e) => { if (e.type === EVENT_CLOSED) pump.stop(); });
|
|
343
|
+
pump.onGap((missing) => console.warn(`漏了 ${missing} 条事件`));
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
- `STATE` 载荷是**快照**:`width` / `height` / `dpi` 与四位状态(`maximized` / `minimized` / `focused` / `dark`)。
|
|
347
|
+
- `CLOSED` 是终态。它由原生侧在 teardown 全部做完、窗口句柄已置空之后写出,所以
|
|
348
|
+
「看到 `CLOSED`」等价于「可以安全重启窗口」——这是它优于 `isAlive()` 的地方。
|
|
349
|
+
- 原生侧环形队列容量 128 条,满时丢最旧。丢失量通过 `seq` 缺口如实上报(`onGap`),
|
|
350
|
+
不会伪装成「什么都没发生」。
|
|
351
|
+
- 事件泵的定时器默认 `unref`(不阻止进程退出)。若靠它等窗口关闭,传 `keepAlive: true`。
|
|
227
352
|
|
|
228
353
|
### `bun-app-window/debug` — 诊断与排障接口
|
|
229
354
|
|
|
@@ -399,13 +524,13 @@ bun run scripts/gate.ts --clean # 同上,但在 HEAD 的干净副本里
|
|
|
399
524
|
|
|
400
525
|
`-Clean` 复现了托管 runner 的条件:把 `HEAD` clone 到 `%TEMP%`,按 lockfile 安装依赖,再在其中跑门禁。这样本地产物(`.build/`、`node_modules/`、`vendor/webview2/`)就无法掩盖缺失的文件。耗时约两秒。未提交改动会被提示并排除在干净副本之外——如需验证工作区现状,请去掉 `-Clean`。
|
|
401
526
|
|
|
402
|
-
门禁分三段:`bun check`、`bun test`,以及消费方验收(`scripts/verify-pack.ts`)。第三段会把 tarball 打进 `%TEMP%`,装进一个临时工程,对那个工程做类型检查,再运行 `scripts/consumer-probe.ts
|
|
527
|
+
门禁分三段:`bun check`、`bun test`,以及消费方验收(`scripts/verify-pack.ts`)。第三段会把 tarball 打进 `%TEMP%`,装进一个临时工程,对那个工程做类型检查,再运行 `scripts/consumer-probe.ts`——断言覆盖全部子路径、DLL 是否真从 `node_modules` 里被 `dlopen`、以及 `libInfo.source` 是否为 `package`(条数由探针自报)。离线可跑,约 0.4 秒,且默认拒绝:任何没在允许清单里登记的文件被打进包,门禁就红。只有在改动门禁自身时才用 `-SkipPack`。
|
|
403
528
|
|
|
404
529
|
底层命令仍可直接调用:
|
|
405
530
|
|
|
406
531
|
```bash
|
|
407
532
|
bun check # 执行 TypeScript 类型检查
|
|
408
|
-
bun test # 运行自动化测试套件(位于 tests
|
|
533
|
+
bun test # 运行自动化测试套件(位于 tests/)
|
|
409
534
|
bun run demo # 启动交互式自检示例程序(需在桌面会话下运行)
|
|
410
535
|
```
|
|
411
536
|
|