bun-app-window 0.4.0 → 0.5.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 CHANGED
@@ -4,6 +4,115 @@ 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
+ ## [0.5.0] - 2026-10-21
8
+
9
+ **Upgrading is enough for the first two items — no code changes on your side.** The third changes how you build a single-file executable, and it fixes something that never worked.
10
+
11
+ ### Fixed
12
+
13
+ - **Keyboard shortcuts work again.** `F11`, `F12`, `F5`, `Ctrl+R`, `Ctrl+0` and `Ctrl±` were all dead, while `Ctrl+wheel` zoom kept working. The cause was message routing, not the handling logic: `WM_KEYDOWN` is delivered to the **focused** window, and focus sits on WebView2's render child window (`Chrome_RenderWidgetHostHWND`), which does not forward unhandled keys to its parent. Every `handle_input_message` call site on the two window procedures was unreachable by real keystrokes. `Ctrl+wheel` survived only because `WM_MOUSEWHEEL` *is* forwarded to the parent by the focused window's `DefWindowProc`.
14
+
15
+ The bug is easy to misdiagnose, because `PostMessage(hwnd, WM_KEYDOWN, VK_F11, 0)` toggles fullscreen correctly — that experiment simply bypasses what broke.
16
+
17
+ The shell now subscribes to `ICoreWebView2Controller::add_AcceleratorKeyPressed`, which fires before the key reaches the page and does not depend on the focus chain. Key semantics live in one place (`handle_accelerator_key`), shared by the callback and the surviving `WM_KEYDOWN` path; only `KEY_DOWN` / `SYSTEM_KEY_DOWN` are handled (handling `KEY_UP` would toggle twice and look like "nothing installed"), and handled keys call `put_Handled(TRUE)`.
18
+
19
+ - **`FLAG_ACCEPT_DROP` now delivers full paths.** With the flag on, pages received a DOM `drop` carrying only `name` and `size`; the promised `shell-msg { t: "drop", paths: [...] }` never arrived. `DragAcceptFiles` only sets `WS_EX_ACCEPTFILES` on the **top-level** window, but OLE hit-testing walks to the deepest window under the cursor (`webview_widget` → `Chrome_WidgetWin_1` → `Chrome_RenderWidgetHostHWND` → `Intermediate D3D Window`, all measured `ACCEPTFILES=False`). WebView2 registers its own `IDropTarget` on the render child, takes the drop, and turns it into a DOM event — the top-level default drop target never sees it. The three `ChangeWindowMessageFilterEx` calls only allow messages *in*; they do not decide where a message *goes*.
20
+
21
+ The fix is upstream: `ICoreWebView2Controller4::put_AllowExternalDrop(FALSE)` steps aside from that internal target, so the OLE chain continues to the parent and `WM_DROPFILES` reaches `handle_drop`. No hand-written `IDropTarget` — that would fight WebView2's internals, and the layering can change between releases (which is exactly how this was hit). On an older runtime the `QueryInterface` fails and the shell keeps WebView2's default behaviour rather than blocking startup.
22
+
23
+ - **Closing the window no longer keeps the process alive for five more seconds.** `App.stop()` used `Promise.race([closed.promise, Bun.sleep(CLOSE_TIMEOUT_MS)])`. A race takes the first settled promise and **does not cancel the loser**, so on the normal path the 5-second `Bun.sleep` became an un-awaited, un-unref'd timer that held Bun's event loop open. Measured: `race returned @ 9 ms`, process exit `@ 5010 ms`; a real window close took 5043 ms. Now a cancellable `setTimeout` with `finally { clearTimeout(timer) }`, and no `AbortSignal` on `Bun.sleep` (unsupported).
24
+
25
+ One measurement note worth keeping: the criterion has to be **when the process exits**, not how long `stop()` takes. With `clearTimeout` removed, `stop()` still returned in 42 ms while the process took 5.9 s to exit.
26
+
27
+ - **The single-file executable actually embeds the native library.** `libInfo.source === "embedded"` was the documented acceptance criterion for shape B, and it had never been achievable: `src/host.ts` did not import `bun-app-window/embedded`, so the bundler recorded no `type: "file"` dependency, `resolveNativeLib` could not find embedded bytes, and the only surviving fallback was "put `libshell.dll` next to the exe" — which reports `source: "package"`, not `embedded`. Builds succeeded and the window came up, so nobody looked further.
28
+
29
+ Adding the side-effect import fixes it. Measured in an empty directory with only the `.exe`, after deleting `%LOCALAPPDATA%\BunAppWindow\runtime\`:
30
+
31
+ ```text
32
+ ExitCode 0
33
+ [lib] source=embedded path=...\BunAppWindow\runtime\libshell-15ef6452e3fcc26f.dll
34
+ ```
35
+
36
+ A second run reports `source=embedded` again and leaves the extracted file's mtime untouched (`reused === true`). The bundle grew from 96,158,208 to 97,165,824 bytes — the difference is exactly `dist/libshell.dll`. This is also the first time `ADR 0003`'s self-managed extraction had been exercised by a real executable.
37
+
38
+ ### Changed
39
+
40
+ - **WebView2 C/C++ SDK upgraded from `1.0.1150.38` to `1.0.4258.31`.** `AllowExternalDrop` needs SDK `1.0.1185.39` or newer and did not exist in the previous vendored copy, which is why the drag-and-drop fix above was impossible before. `AdditionalObjects` and `ICoreWebView2File` arrived with it; they are not used, because they are reachable only through `postMessage`, and this shell keeps page communication on WebSocket. The old nupkg is removed and the new one is committed, so offline rebuilds behave exactly as before.
41
+
42
+ `CORE_WEBVIEW_TARGET_PRODUCT_VERSION` moves from `99.0.1150.38` to `154.0.4258.31`. This project already requires Bun ≥ 1.4.3, TypeScript ≥ 7.0.2, Windows x64 and a working WebView2 Runtime, and **does not aim for compatibility with older runtimes** — that is a decision, not an oversight.
43
+
44
+ - **`timeoutMs: 0` is now documented and guarded.** `pickFolder()` blocks the host thread for however long a person browses, so `connect({ timeoutMs: 0 })` is required when exposing it over IPC — otherwise the client rejects after 30 seconds while the host is still working, and a missing `.catch` makes that entirely silent. Both halves are now in the docs, and two assertions keep the contract: one behavioural (`timeoutMs: 0` still pending after 50 ms) and one static (no `||` fallback, which would turn `0` back into `30000`).
45
+
46
+ Worth knowing beyond the timeout: `pickFolder()` blocks the **Bun main thread**, so timers in that process do not fire and other IPC calls queue up while the dialog is open. Lowering the timeout surfaces the symptom earlier; it does not fix the blocking. Real concurrency means running it in a Worker.
47
+
48
+ ### Documentation
49
+
50
+ - `docs/pitfalls.md` gains 2.13 (shortcuts: `WM_KEYDOWN` goes to the focused window) and 2.14 (drag and drop: the deepest window wins the OLE hit test), plus 4.9 for the `embedded` criterion that nothing satisfied. Each records the double-sided verification, including the probe mistakes made along the way — a probe that called `SetFocus` on the top-level window measured the old code path and reported success with the fix removed.
51
+ - `AGENTS.md` records both routing mechanisms as implementation rules and replaces the open question about minimum runtime with the compatibility decision above.
52
+ - Three READMEs gain the `timeoutMs` section and cross-reference it from the `pickFolder` row.
53
+
54
+ ### Verification
55
+
56
+ | Command | Result |
57
+ | --- | --- |
58
+ | `bun check` | 68 files |
59
+ | `bun test` | 556 pass / 0 fail |
60
+ | `bun run scripts/gate.ts` | 4 stages, PASSED |
61
+ | `bun run scripts/gate.ts --clean` | PASSED (clean) |
62
+ | `bun run scripts/verify-dll.ts` | PASSED, `dist/libshell.dll` matches `native/shell.cc` |
63
+ | `scripts/probe-window-events.ts` | 21 checks |
64
+ | `scripts/probe-accelerator-keys.ts` | 6 checks |
65
+ | `scripts/probe-drop.ts` | 6 checks |
66
+ | shape B in an empty directory | exit 0, `source=embedded`, `reused=true` |
67
+
68
+ Each of the two new probes was verified in both directions: removing `install_accelerator_handler()` makes a real `F11` do nothing (1826 px → 1826 px), and removing `handle_drop(...)` leaves the `HDROP` handle alive. With the fix in place both pass.
69
+
70
+ ### Still manual
71
+
72
+ Dragging a real file into a window to confirm the page sees the **full path**. A synthetic `HDROP` proves the message reaches `handle_drop`; it cannot prove that OLE hit-testing now resolves to the parent window, because that requires an actual cross-process drag.
73
+
74
+ ## [0.4.1] - 2026-10-21
75
+
76
+ ### Fixed
77
+
78
+ - **`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:
79
+
80
+ ```text
81
+ node_modules/bun-app-window/src/ui/titlebar.ts(237,27): error TS2352:
82
+ Conversion of type 'typeof globalThis' to type '{ document?: TitleBarDocument; }'
83
+ may be a mistake because neither type sufficiently overlaps with the other.
84
+ If this was intentional, convert the expression to 'unknown' first.
85
+ ```
86
+
87
+ `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.
88
+
89
+ ### Changed
90
+
91
+ - **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.
92
+
93
+ **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.
94
+
95
+ **`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.
96
+
97
+ **`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.
98
+
99
+ **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.
100
+
101
+ **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.
102
+
103
+ **The tarball figures were stale** — 19 files / ~0.36 MB against the measured 21 files / ~0.4 MB.
104
+
105
+ - **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:
106
+
107
+ ```ts
108
+ import htmlText from "./index.html" with { type: "text" };
109
+ const html = htmlText as unknown as string;
110
+ ```
111
+
112
+ ### Note
113
+
114
+ - **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.
115
+
7
116
  ## [0.4.0] - 2026-10-21
8
117
 
9
118
  ### Added
@@ -285,7 +394,9 @@ First public release.
285
394
  - Not yet verified in managed environments that enforce AppLocker, WDAC, or third-party EDR behaviour blocking.
286
395
  - No commercially trusted code signing: only a self-signed certificate has been exercised end to end.
287
396
 
288
- [Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.0...HEAD
397
+ [Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.5.0...HEAD
398
+ [0.5.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.1...v0.5.0
399
+ [0.4.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.0...v0.4.1
289
400
  [0.4.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.3.0...v0.4.0
290
401
  [0.3.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.2...v0.3.0
291
402
  [0.2.2]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.1...v0.2.2
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 — 19 files, ~0.36 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`.
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
 
@@ -97,7 +97,7 @@ Note: `createShell(opts)` only resolves and loads the native DLL without opening
97
97
  | `reload()` / `openDevTools()` / `toggleFullscreen()` | Equivalent to `Ctrl+R`, `F12`, and `F11` respectively |
98
98
  | `setBrowserUi(mask)` | Disables specific default browser behaviors using a bitwise OR of `BROWSER_UI_*` flags |
99
99
  | `showContextMenu(x, y, items)` | Displays a native Win32 context menu at the specified screen coordinates (see below) |
100
- | `pickFolder(title?)` | Opens a native folder selection dialog (synchronously blocks the calling thread) |
100
+ | `pickFolder(title?)` | Opens a native folder selection dialog (synchronously blocks the calling thread; **when exposed over IPC, pair it with `timeoutMs: 0`**, see below) |
101
101
  | `libInfo` | Metadata of the resolved native library (file path, resolution source, SHA-256 hash) |
102
102
  | `symbols` | Escape hatch providing direct access to the raw `dlopen` FFI symbols |
103
103
 
@@ -153,7 +153,12 @@ start hits "already started"). `app` collapses all of it into one call:
153
153
  // main.ts
154
154
  import { createApp } from "bun-app-window/app";
155
155
  import { BROWSER_UI_ALL, FLAG_FRAMELESS } from "bun-app-window";
156
- import html from "./index.html" with { type: "text" };
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;
157
162
 
158
163
  const app = await createApp({
159
164
  html,
@@ -167,6 +172,7 @@ const app = await createApp({
167
172
  });
168
173
 
169
174
  await app.wait(); // resolves when the window closes
175
+ await app.stop(); // releases the event pump and the server; idempotent
170
176
  ```
171
177
 
172
178
  ```ts
@@ -195,10 +201,18 @@ Notes:
195
201
  - **Does not default to `FLAG_FRAMELESS`.** A frameless window has no system title bar, so it cannot
196
202
  be dragged until the page mounts `mountTitleBar`. The default is the side that works even when the
197
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.
198
208
  - **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.
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.
202
216
  - **Not re-exported from the main entry.** It is a subpath like `./state` and `./ipc`. It imports
203
217
  `openShell` from `./index`; exporting it from `index.ts` would make that a cycle.
204
218
 
@@ -249,39 +263,78 @@ api.on("windowState", (s) => bar.setMaximized(s.maximized));
249
263
 
250
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.
251
265
 
252
- ### `bun-app-window/titlebar` — Windows 11 Title Bar
266
+ #### `timeoutMs` — long-running human interaction must opt out
253
267
 
254
- Mounts a customizable Windows 11 title bar inside frameless windows:
268
+ Calls time out after **30 seconds** by default. That is right for ordinary methods and wrong for anything a human sits in front of:
255
269
 
256
270
  ```ts
257
- // Page
258
- import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
259
-
260
- const bar = mountTitleBar({
261
- title: "My App",
262
- icon: "/icon.svg",
263
- bridge: bridgeFromWindowService(api.window),
264
- });
271
+ // Host: pickFolder wraps SHBrowseForFolder — synchronous, and however long the user takes
272
+ const service = { pickFolder: () => shell.pickFolder() };
265
273
 
266
- api.on("windowState", (s) => bar.setMaximized(s.maximized));
274
+ // Page: you must disable the timeout, or the client rejects while the host is still working
275
+ const api = await connect<Api, Events>({ timeoutMs: 0 });
267
276
  ```
268
277
 
269
- Expose the four corresponding window control methods on the host IPC service:
278
+ `0` means **no timeout** (not "a zero-millisecond timeout"). Two ways this bites, neither caught by any gate:
279
+
280
+ - The page forgets `.catch` → the rejection is completely silent; the UI just does nothing after the user picks.
281
+ - Cancelling returns quickly, so only a **slow** pick reproduces it — easy to write off as flaky.
282
+
283
+ One more layer worth knowing: `shell.pickFolder()` **blocks the Bun main thread**. While the dialog is open, timers in that process do not fire and other IPC calls queue up (they are not rejected — they simply get no answer). The client's 30-second timer lives in the browser renderer process and is unaffected, so lowering the timeout does not fix the blocking, only surfaces it sooner. Real concurrency means running `pickFolder` in a Worker.
284
+
285
+ #### Reserved client names
286
+
287
+ The client is a `Proxy`: a handful of names resolve to the client itself and never reach a remote
288
+ method. If your service exposes a method under one of these names, **the server-side method becomes
289
+ unreachable** — the page calls the client's own member instead, and neither end reports an error.
290
+
291
+ | Name | What the page actually gets |
292
+ | --- | --- |
293
+ | `on` / `off` | Event subscription helpers |
294
+ | `close()` | Closes the WebSocket channel — every later call is rejected |
295
+ | `connected` | `boolean`: whether the channel is open |
296
+ | `pending` | `number` of calls still awaiting a reply |
297
+ | `then` | `undefined`, so `await client` cannot hang on a never-resolving thenable |
298
+
299
+ Symbol keys are reserved as well. Pick a different service name (`winClose` instead of `close`) and
300
+ map it back on the page side — see the title bar section below.
301
+
302
+ ### `bun-app-window/titlebar` — Windows 11 Title Bar
303
+
304
+ Mounts a customizable Windows 11 title bar inside frameless windows. The IPC service is **flat** —
305
+ there is no `window` namespace — and one of the four natural names is taken by the client itself,
306
+ so the host exposes `winClose` and the page maps it back:
270
307
 
271
308
  ```ts
272
- // Host
309
+ // Host — flat service; `close` is reserved by the IPC client
273
310
  createIpcServer({
274
311
  service: {
275
- window: {
276
- minimize: () => shell.minimize(),
277
- toggleMaximize: () => shell.toggleMaximize(),
278
- close: () => shell.close(),
279
- drag: () => shell.drag(),
280
- },
312
+ minimize: () => shell.minimize(),
313
+ toggleMaximize: () => shell.toggleMaximize(),
314
+ winClose: () => shell.close(),
315
+ drag: () => shell.drag(),
281
316
  },
282
317
  });
283
318
  ```
284
319
 
320
+ ```ts
321
+ // Page — build the bridge explicitly
322
+ import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
323
+
324
+ const bar = mountTitleBar({
325
+ title: "My App",
326
+ icon: "/icon.svg",
327
+ bridge: bridgeFromWindowService({
328
+ minimize: () => api.minimize(),
329
+ toggleMaximize: () => api.toggleMaximize(),
330
+ close: () => api.winClose(), // mapped back to the bridge's own name
331
+ drag: () => api.drag(),
332
+ }),
333
+ });
334
+
335
+ api.on("windowState", (s) => bar.setMaximized(s.maximized));
336
+ ```
337
+
285
338
  Implementation notes:
286
339
 
287
340
  - Native dragging: Calling `shell.drag()` dispatches a native `WM_NCLBUTTONDOWN` (`HTCAPTION`) message, preserving Windows 11 Snap Layouts and double-click maximize behavior.
@@ -375,11 +428,14 @@ Exposes internal metrics and hit-testing utilities. Note: This module is exclude
375
428
  import { debugOf } from "bun-app-window/debug";
376
429
 
377
430
  const d = debugOf(shell);
378
- d.metrics(); // Returns pos / win / client / work / border / round geometry
379
- d.dpi(); // Returns DPI awareness level and scaling factor
431
+ d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
432
+ d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
380
433
  d.hittest(x, y);
381
434
  ```
382
435
 
436
+ `metrics()` and `dpi()` return **formatted text**, not objects. Parse it in throwaway tooling if you
437
+ must; do not put a parser in production code, because the layout is not part of any contract.
438
+
383
439
  ### `bun-app-window/native` — The Prebuilt DLL
384
440
 
385
441
  Resolves to `dist/libshell.dll` itself, so `import.meta.resolve()` hands you a real path you can copy next to your executable:
@@ -421,13 +477,10 @@ Note: `F11`, `F12`, and `Ctrl±` zoom shortcuts are handled directly by the nati
421
477
  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:
422
478
 
423
479
  ```ts
424
- // Host: expose the menu capability on the window service
480
+ // Host: expose the menu capability on the flat service
425
481
  createIpcServer({
426
482
  service: {
427
- window: {
428
- showContextMenu: (x: number, y: number, items: string) =>
429
- shell.showContextMenu(x, y, items),
430
- },
483
+ showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
431
484
  },
432
485
  });
433
486
  ```
@@ -436,7 +489,7 @@ createIpcServer({
436
489
  // Page: trigger the native menu
437
490
  document.addEventListener("contextmenu", async (e) => {
438
491
  e.preventDefault();
439
- await api.window.showContextMenu(
492
+ await api.showContextMenu(
440
493
  Math.round(e.screenX),
441
494
  Math.round(e.screenY),
442
495
  "open|Open\nrefresh|Refresh\n-\nquit|Quit"
@@ -508,6 +561,19 @@ Tips:
508
561
  - A side-effect import `import "bun-app-window/embedded";` also works automatically.
509
562
  - Always pass `--windows-hide-console` for GUI builds; omitting it will spawn a black command prompt window alongside your application.
510
563
 
564
+ ### 6. Two Traps That Type-Check
565
+
566
+ Neither of these fails `bun check`; both produce a window that renders and then does nothing.
567
+
568
+ - **The page bundle is an ES module, so the `<script>` tag needs `type="module"`.** A build with a
569
+ top-level `await` is a syntax error inside a classic `<script>`, and the **whole script never
570
+ runs** — while the page still renders, because the markup is static. Nothing on screen points at
571
+ the cause.
572
+ - **Namespace your own `data-*` selectors.** The title bar renders buttons with
573
+ `data-act="minimize|maximize|close"` and listens for clicks on `document`. A host that also binds
574
+ `button[data-act]` receives the same click twice and logs its own "unknown action: maximize".
575
+ Prefix your attributes (`data-mvp-act`): a generic name is a shared namespace, not yours.
576
+
511
577
  ## Internals & Reference
512
578
 
513
579
  ### Native Library Resolution Order
@@ -567,7 +633,7 @@ For window enumeration (`EnumWindows`) or automated screenshot scripts, filter b
567
633
  ## Acknowledgments
568
634
 
569
635
  - Platform windowing layer based on [webview/webview](https://github.com/webview/webview) `0.12.0` (MIT License, Serge Zaitsev & Steffen André Langnes). `vendor/webview/` contains an unmodified upstream copy.
570
- - WebView2 SDK provided by [Microsoft.Web.WebView2](https://www.nuget.org/packages/Microsoft.Web.WebView2) `1.0.1150.38` (NuGet package vendored for offline builds). The WebView2 Runtime is provided by the OS.
636
+ - WebView2 SDK provided by [Microsoft.Web.WebView2](https://www.nuget.org/packages/Microsoft.Web.WebView2) `1.0.4258.31` (NuGet package vendored for offline builds). The WebView2 Runtime is provided by the OS.
571
637
  - Co-authored-by DeepSeek
572
638
 
573
639
  ## License
package/README.zh-CN.md CHANGED
@@ -46,7 +46,7 @@ bun add bun-app-window
46
46
 
47
47
  ### 包内包含什么
48
48
 
49
- 发布的 tarball 只有预编译的 `dist/libshell.dll` 与 TypeScript 源码,共 19 个文件(约 0.36 MB)。原生 C++ 层(`native/`)与上游 vendored 头文件(`vendor/`)**不随包分发**:它们只在重编 `libshell.dll` 时需要,而重编必须 clone 仓库。因此 `bun run build:native` 与 `build:exe` 是本仓库专用脚本,装进 `node_modules` 后执行没有意义。
49
+ 发布的 tarball 只有预编译的 `dist/libshell.dll` 与 TypeScript 源码,共 21 个文件(约 0.4 MB)。原生 C++ 层(`native/`)与上游 vendored 头文件(`vendor/`)**不随包分发**:它们只在重编 `libshell.dll` 时需要,而重编必须 clone 仓库。因此 `bun run build:native` 与 `build:exe` 是本仓库专用脚本,装进 `node_modules` 后执行没有意义。
50
50
 
51
51
  ## 快速开始
52
52
 
@@ -97,7 +97,7 @@ shell.setTitle("Doing work…");
97
97
  | `reload()` / `openDevTools()` / `toggleFullscreen()` | 页面重载、打开开发者工具、切换全屏(分别对应 `Ctrl+R` / `F12` / `F11`) |
98
98
  | `setBrowserUi(mask)` | 禁用指定的浏览器默认行为,传入 `BROWSER_UI_*` 标志位的按位或组合 |
99
99
  | `showContextMenu(x, y, items)` | 在指定屏幕坐标弹出 Win32 原生上下文菜单(详见下文用法) |
100
- | `pickFolder(title?)` | 调用原生文件夹选择对话框(同步阻塞当前调用线程) |
100
+ | `pickFolder(title?)` | 调用原生文件夹选择对话框(同步阻塞当前调用线程;**经 IPC 暴露时务必配 `timeoutMs: 0`**,见下文) |
101
101
  | `libInfo` | 原生动态库解析元数据(包含实际路径、加载来源、SHA-256 校验值) |
102
102
  | `symbols` | 底层直通接口:直接访问通过 `dlopen` 导出的底层 C 符号 |
103
103
 
@@ -150,7 +150,12 @@ shell.setTitle("Doing work…");
150
150
  // main.ts
151
151
  import { createApp } from "bun-app-window/app";
152
152
  import { BROWSER_UI_ALL, FLAG_FRAMELESS } from "bun-app-window";
153
- import html from "./index.html" with { type: "text" };
153
+ import htmlText from "./index.html" with { type: "text" };
154
+
155
+ // `with { type: "text" }` 让 Bun 在打包期把文件内联进去,所以 `bun build --compile` 后仍然成立。
156
+ // TS 7.0.2 看不到这条声明——bun-types 把它 gate 在 `typesVersions >= 7.1` 后面,
157
+ // 于是这个 import 被解析成 `HTMLBundle` 而不是 `string`。桥梁就落在这里。
158
+ const html = htmlText as unknown as string;
154
159
 
155
160
  const app = await createApp({
156
161
  html,
@@ -159,11 +164,13 @@ const app = await createApp({
159
164
  browserUi: BROWSER_UI_ALL,
160
165
  service: {
161
166
  greet: (name: string) => `hi ${name}`,
167
+ // 人机交互的调用耗时不可控:页面侧必须 connect({ timeoutMs: 0 }),见 ipc 一节
162
168
  pickFolder: () => app.shell.pickFolder(),
163
169
  },
164
170
  });
165
171
 
166
172
  await app.wait(); // 窗口关闭时 resolve
173
+ await app.stop(); // 释放事件泵与服务;幂等,已关闭时立即返回
167
174
  ```
168
175
 
169
176
  ```ts
@@ -188,9 +195,15 @@ await api.greet("Ada");
188
195
 
189
196
  - **默认不带 `FLAG_FRAMELESS`。** 无边框窗口没有系统标题栏就拖不动,必须由页面先挂上
190
197
  `mountTitleBar`。默认值选的是「页面什么都不做也能用」的那一侧。
198
+ - **`wait()` 只负责等。** 它在 `EVENT_CLOSED` 上 resolve,但不会停事件泵、也不会关服务——而
199
+ 事件泵的定时器是 ref 的(`keepAlive: true`),所以停在这一行的进程会在窗口消失后继续挂着。
200
+ 必须跟一句 `stop()`:它幂等,且关闭已被观察到时会立即返回。
191
201
  - **静态资源只吃内联字符串。** `with { type: "text" }` 得到的是编译期常量:不碰文件系统、
192
- 不产生临时文件、`bun build --compile` 后仍然成立。要从磁盘读东西就写个 `routes` 函数 ——
193
- 五行,而且路径解析的问题留在你这边,不会渗进骨架。
202
+ 不产生临时文件、`bun build --compile` 后仍然成立。TS 7.0.2 会把这条 import 解析成
203
+ `HTMLBundle` 而不是 `string`(`bun-types` 把正确的声明 gate 在 `typesVersions >= 7.1`
204
+ 后面),示例里的 `as unknown as string` 就是为此而写。别用 `Bun.file().text()` 去「修」它:
205
+ 那是运行期读盘,HTML 就不在编译产物里了。要从磁盘读东西就写个 `routes` 函数 —— 五行,
206
+ 而且路径解析的问题留在你这边,不会渗进骨架。
194
207
  - **不从主入口导出。** 它和 `./state`、`./ipc` 一样是子路径。它从 `./index` 导入
195
208
  `openShell`;若 `index.ts` 反过来导出它,就成了循环。
196
209
 
@@ -240,39 +253,80 @@ api.on("windowState", (s) => bar.setMaximized(s.maximized));
240
253
 
241
254
  默认情况下,`connect` 会从当前页面 URL 的查询参数 `?t=` 中读取鉴权 Token 并连接至 `/ws`。若需自定义连接方式,可显式传入 `url` 或自定义 `channel` 实例。
242
255
 
243
- ### `bun-app-window/titlebar` — Windows 11 风格标题栏
256
+ #### `timeoutMs` —— 长时人机交互必须显式关掉
244
257
 
245
- 用于在无边框模式下快速挂载符合 Windows 11 视觉规范的自定义标题栏:
258
+ 单次调用默认 **30 秒**超时。这对普通方法合理,对**人机交互**是致命的:
246
259
 
247
260
  ```ts
248
- // 页面端
249
- import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
250
-
251
- const bar = mountTitleBar({
252
- title: "我的应用",
253
- icon: "/icon.svg",
254
- bridge: bridgeFromWindowService(api.window),
255
- });
261
+ // 服务端:pickFolder 内部是 SHBrowseForFolder,同步阻塞、耗时完全由用户决定
262
+ const service = { pickFolder: () => shell.pickFolder() };
256
263
 
257
- api.on("windowState", (s) => bar.setMaximized(s.maximized));
264
+ // 页面端:必须关掉超时,否则用户挑久一点,客户端先 reject,而服务端还在正常阻塞
265
+ const api = await connect<Api, Events>({ timeoutMs: 0 });
258
266
  ```
259
267
 
260
- 宿主端需在 IPC 服务中暴露对应的四个窗口控制方法:
268
+ `0` 表示**不超时**(不是「0 毫秒超时」)。踩坑的形态有两种,都不会让门禁变红:
269
+
270
+ - 页面没接 `.catch` → 拒绝完全静默,表现为「选完了界面毫无反应」。
271
+ - 用户点了「取消」很快返回,所以只有**挑久了**才复现,容易被判成偶发。
272
+
273
+ 还有一层要知道:`shell.pickFolder()` **阻塞的是 Bun 主线程**。对话框打开期间,
274
+ 同一进程里的定时器不会触发,其他 IPC 调用也会排队(不是 reject,是没有响应)。
275
+ 客户端那 30 秒定时器跑在浏览器渲染进程里,不受影响——所以调短超时并不能解决阻塞,
276
+ 只能让症状更早暴露。需要并发就得把 `pickFolder` 放进 Worker 里调。
277
+
278
+ #### 客户端保留名
279
+
280
+ 客户端是一个 `Proxy`:有几个名字会解析到客户端自己身上,永远到不了远端方法。服务表里若有
281
+ 同名方法,**那个服务端方法就永远调不到**——页面调的是客户端自己的成员,两端都不会报错。
282
+
283
+ | 名字 | 页面实际拿到的东西 |
284
+ | --- | --- |
285
+ | `on` / `off` | 事件订阅辅助函数 |
286
+ | `close()` | 关闭 WebSocket 通道——之后所有调用都会被 reject |
287
+ | `connected` | `boolean`,通道当前是否可用 |
288
+ | `pending` | `number`,尚未收到回复的调用数 |
289
+ | `then` | `undefined`,否则 `await client` 会把这个永不兑现的 thenable 等下去 |
290
+
291
+ Symbol 键同样保留。给服务方法换个名字(`winClose` 而不是 `close`),在页面侧映射回去 ——
292
+ 见下面的标题栏一节。
293
+
294
+ ### `bun-app-window/titlebar` — Windows 11 风格标题栏
295
+
296
+ 用于在无边框模式下快速挂载符合 Windows 11 视觉规范的自定义标题栏。IPC 服务是**扁平的**——
297
+ 没有 `window` 这一层命名空间——而且四个最自然的名字里有一个被客户端自己占了,所以宿主端
298
+ 暴露 `winClose`,页面侧再映射回去:
261
299
 
262
300
  ```ts
263
- // 宿主端
301
+ // 宿主端 —— 扁平服务;`close` 是 IPC 客户端的保留名
264
302
  createIpcServer({
265
303
  service: {
266
- window: {
267
- minimize: () => shell.minimize(),
268
- toggleMaximize: () => shell.toggleMaximize(),
269
- close: () => shell.close(),
270
- drag: () => shell.drag(),
271
- },
304
+ minimize: () => shell.minimize(),
305
+ toggleMaximize: () => shell.toggleMaximize(),
306
+ winClose: () => shell.close(),
307
+ drag: () => shell.drag(),
272
308
  },
273
309
  });
274
310
  ```
275
311
 
312
+ ```ts
313
+ // 页面端 —— 显式拼出 bridge
314
+ import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
315
+
316
+ const bar = mountTitleBar({
317
+ title: "我的应用",
318
+ icon: "/icon.svg",
319
+ bridge: bridgeFromWindowService({
320
+ minimize: () => api.minimize(),
321
+ toggleMaximize: () => api.toggleMaximize(),
322
+ close: () => api.winClose(), // 桥接时再映射回 close
323
+ drag: () => api.drag(),
324
+ }),
325
+ });
326
+
327
+ api.on("windowState", (s) => bar.setMaximized(s.maximized));
328
+ ```
329
+
276
330
  实现细节:
277
331
 
278
332
  - 原生拖拽体验:拖拽操作通过调用 `shell.drag()` 交由底层发送 `WM_NCLBUTTONDOWN`(`HTCAPTION`)消息处理,完整保留 Windows 11 贴边分屏(Snap Layouts)与双击最大化等系统级行为。
@@ -358,11 +412,14 @@ pump.onGap((missing) => console.warn(`漏了 ${missing} 条事件`));
358
412
  import { debugOf } from "bun-app-window/debug";
359
413
 
360
414
  const d = debugOf(shell);
361
- d.metrics(); // 获取 pos / win / client / work / border / round 等几何度量
362
- d.dpi(); // 获取当前 DPI 感知级别与缩放因子
415
+ d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
416
+ d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
363
417
  d.hittest(x, y);
364
418
  ```
365
419
 
420
+ `metrics()` 与 `dpi()` 返回的是**格式化文本**,不是对象。真要解析就写进一次性脚本里,别把解析器
421
+ 塞进生产代码——这个格式不属于任何契约。
422
+
366
423
  ### `bun-app-window/native` — 预编译动态库本体
367
424
 
368
425
  该子路径解析到 `dist/libshell.dll` 本身,`import.meta.resolve()` 拿到的是可直接拷到 exe 旁边的真实路径:
@@ -404,13 +461,10 @@ const shell = await openShell({ url, browserUi: BROWSER_UI_ALL });
404
461
  禁用默认浏览器菜单后,可通过 `showContextMenu` 唤起基于 Win32 `TrackPopupMenu` 绘制的原生菜单。页面无法直接调用它——需要先在 IPC 服务上暴露:
405
462
 
406
463
  ```ts
407
- // 宿主端:把菜单能力挂到 window 服务上
464
+ // 宿主端:把菜单能力挂到扁平服务上
408
465
  createIpcServer({
409
466
  service: {
410
- window: {
411
- showContextMenu: (x: number, y: number, items: string) =>
412
- shell.showContextMenu(x, y, items),
413
- },
467
+ showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
414
468
  },
415
469
  });
416
470
  ```
@@ -419,7 +473,7 @@ createIpcServer({
419
473
  // 页面端触发菜单
420
474
  document.addEventListener("contextmenu", async (e) => {
421
475
  e.preventDefault();
422
- await api.window.showContextMenu(
476
+ await api.showContextMenu(
423
477
  Math.round(e.screenX),
424
478
  Math.round(e.screenY),
425
479
  "open|打开\nrefresh|刷新\n-\nquit|退出"
@@ -489,6 +543,18 @@ bun build --compile --windows-hide-console src/index.ts --outfile dist/app.exe
489
543
  - 仅通过副作用导入 `import "bun-app-window/embedded";` 同样生效。
490
544
  - 打包桌面端应用时请务必添加 `--windows-hide-console` 参数,否则启动时将伴随弹出黑色命令行控制台窗口。
491
545
 
546
+ ### 6. 两个类型检查抓不到的坑
547
+
548
+ 这两条都不会让 `bun check` 变红,但都会得到一个「渲染正常、什么也不做」的窗口。
549
+
550
+ - **页面 bundle 是 ES 模块,`<script>` 标签必须写 `type="module"`。** 带顶层 `await` 的构建
551
+ 产物塞进经典 `<script>` 就是语法错,**整个脚本一行都不执行**——而页面照样渲染,因为 HTML
552
+ 是静态的。屏幕上没有任何东西指向这个原因。
553
+ - **自己的 `data-*` 选择器要给命名空间。** 标题栏的按钮用
554
+ `data-act="minimize|maximize|close"`,并在 `document` 上挂 click 监听。宿主如果也绑
555
+ `button[data-act]`,同一次点击会被接两遍,然后打出自己那句「未知动作:maximize」。给自己的
556
+ 属性加前缀(`data-mvp-act`)——泛化属性名是共享命名空间,不是你的地盘。
557
+
492
558
  ## 底层机制与交互参考
493
559
 
494
560
  ### 原生动态库加载顺序
@@ -548,7 +614,7 @@ bun run demo # 启动交互式自检示例程序(需在桌面会话下
548
614
  ## 致谢
549
615
 
550
616
  - 底层跨平台窗口抽象源自 [webview/webview](https://github.com/webview/webview) `0.12.0`(MIT 许可,作者 Serge Zaitsev · Steffen André Langnes)。仓库内 `vendor/webview/` 目录为其源码原样拷贝,未作任何侵入式修改。
551
- - WebView2 C/C++ SDK 源自 [Microsoft.Web.WebView2](https://www.nuget.org/packages/Microsoft.Web.WebView2) `1.0.1150.38`(NuGet 包随仓库分发以支持离线构建),运行时由操作系统提供。
617
+ - WebView2 C/C++ SDK 源自 [Microsoft.Web.WebView2](https://www.nuget.org/packages/Microsoft.Web.WebView2) `1.0.4258.31`(NuGet 包随仓库分发以支持离线构建),运行时由操作系统提供。
552
618
  - Co-authored-by DeepSeek
553
619
 
554
620
  ## 开源许可
package/README.zh-TW.md CHANGED
@@ -46,7 +46,7 @@ bun add bun-app-window
46
46
 
47
47
  ### 套件內容包含什麼
48
48
 
49
- 發佈的 tarball 只有預先編譯的 `dist/libshell.dll` 與 TypeScript 原始碼,共 19 個檔案(約 0.36 MB)。原生 C++ 層(`native/`)與上游 vendored 標頭檔(`vendor/`)**不隨套件散布**:它們只在重新編譯 `libshell.dll` 時需要,而重新編譯必須 clone 儲存庫。因此 `bun run build:native` 與 `build:exe` 是本儲存庫專用指令碼,安裝進 `node_modules` 後執行沒有意義。
49
+ 發佈的 tarball 只有預先編譯的 `dist/libshell.dll` 與 TypeScript 原始碼,共 21 個檔案(約 0.4 MB)。原生 C++ 層(`native/`)與上游 vendored 標頭檔(`vendor/`)**不隨套件散布**:它們只在重新編譯 `libshell.dll` 時需要,而重新編譯必須 clone 儲存庫。因此 `bun run build:native` 與 `build:exe` 是本儲存庫專用指令碼,安裝進 `node_modules` 後執行沒有意義。
50
50
 
51
51
  ## 快速開始
52
52
 
@@ -97,7 +97,7 @@ shell.setTitle("Doing work…");
97
97
  | `reload()` / `openDevTools()` / `toggleFullscreen()` | 頁面重新載入、開啟開發人員工具、切換全螢幕(分別對應 `Ctrl+R` / `F12` / `F11`) |
98
98
  | `setBrowserUi(mask)` | 停用指定的瀏覽器預設行為,傳入 `BROWSER_UI_*` 旗標的按位元 OR 組合 |
99
99
  | `showContextMenu(x, y, items)` | 於指定螢幕座標彈出 Win32 原生右鍵快顯功能表(詳見下文用法) |
100
- | `pickFolder(title?)` | 呼叫原生資料夾選擇對話方塊(同步阻塞目前呼叫執行緒) |
100
+ | `pickFolder(title?)` | 呼叫原生資料夾選擇對話方塊(同步阻塞目前呼叫執行緒;**經 IPC 暴露時務必搭配 `timeoutMs: 0`**,見下文) |
101
101
  | `libInfo` | 原生動態連結程式庫解析中繼資料(包含實際路徑、載入來源、SHA-256 雜湊值) |
102
102
  | `symbols` | 底層直通介面:直接存取透過 `dlopen` 匯出的底層 C 符號 |
103
103
 
@@ -150,7 +150,12 @@ shell.setTitle("Doing work…");
150
150
  // main.ts
151
151
  import { createApp } from "bun-app-window/app";
152
152
  import { BROWSER_UI_ALL, FLAG_FRAMELESS } from "bun-app-window";
153
- import html from "./index.html" with { type: "text" };
153
+ import htmlText from "./index.html" with { type: "text" };
154
+
155
+ // `with { type: "text" }` 讓 Bun 在封裝期把檔案內嵌進去,所以 `bun build --compile` 後仍然成立。
156
+ // TS 7.0.2 看不到這條宣告——bun-types 把它 gate 在 `typesVersions >= 7.1` 之後,
157
+ // 於是這個 import 被解析成 `HTMLBundle` 而不是 `string`。橋樑就落在這裡。
158
+ const html = htmlText as unknown as string;
154
159
 
155
160
  const app = await createApp({
156
161
  html,
@@ -164,6 +169,7 @@ const app = await createApp({
164
169
  });
165
170
 
166
171
  await app.wait(); // 視窗關閉時 resolve
172
+ await app.stop(); // 釋放事件泵與服務;冪等,已關閉時立即回傳
167
173
  ```
168
174
 
169
175
  ```ts
@@ -188,9 +194,15 @@ await api.greet("Ada");
188
194
 
189
195
  - **預設不帶 `FLAG_FRAMELESS`。** 無邊框視窗沒有系統標題列就拖不動,必須由頁面先掛上
190
196
  `mountTitleBar`。預設值選的是「頁面什麼都不做也能用」的那一側。
197
+ - **`wait()` 只負責等。** 它在 `EVENT_CLOSED` 上 resolve,但不會停事件泵、也不會關服務——而
198
+ 事件泵的計時器是 ref 的(`keepAlive: true`),所以停在這一行的行程會在視窗消失後繼續掛著。
199
+ 必須跟一句 `stop()`:它冪等,且關閉已被觀察到時會立即回傳。
191
200
  - **靜態資源只吃內嵌字串。** `with { type: "text" }` 得到的是編譯期常數:不碰檔案系統、
192
- 不產生暫存檔、`bun build --compile` 後仍然成立。要從磁碟讀東西就寫個 `routes` 函式 ——
193
- 五行,而且路徑解析的問題留在你這邊,不會滲進骨架。
201
+ 不產生暫存檔、`bun build --compile` 後仍然成立。TS 7.0.2 會把這條 import 解析成
202
+ `HTMLBundle` 而不是 `string`(`bun-types` 把正確的宣告 gate 在 `typesVersions >= 7.1`
203
+ 之後),範例裡的 `as unknown as string` 就是為此而寫。別用 `Bun.file().text()` 去「修」它:
204
+ 那是執行期讀磁碟,HTML 就不在編譯產物裡了。要從磁碟讀東西就寫個 `routes` 函式 —— 五行,
205
+ 而且路徑解析的問題留在你這邊,不會滲進骨架。
194
206
  - **不從主入口匯出。** 它和 `./state`、`./ipc` 一樣是子路徑。它從 `./index` 匯入
195
207
  `openShell`;若 `index.ts` 反過來匯出它,就成了循環。
196
208
 
@@ -240,39 +252,80 @@ api.on("windowState", (s) => bar.setMaximized(s.maximized));
240
252
 
241
253
  預設情況下,`connect` 會從目前頁面 URL 的查詢參數 `?t=` 中讀取驗證 Token 並連線至 `/ws`。若需自訂連線方式,可明確傳入 `url` 或自訂 `channel` 實例。
242
254
 
243
- ### `bun-app-window/titlebar` — Windows 11 風格標題列
255
+ #### `timeoutMs` —— 長時人機互動必須明確關閉
244
256
 
245
- 用於在無邊框模式下快速掛載符合 Windows 11 視覺規範的自訂標題列:
257
+ 單次呼叫預設 **30 秒**逾時。這對一般方法合理,對**人機互動**卻是致命的:
246
258
 
247
259
  ```ts
248
- // 頁面端
249
- import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
250
-
251
- const bar = mountTitleBar({
252
- title: "我的應用程式",
253
- icon: "/icon.svg",
254
- bridge: bridgeFromWindowService(api.window),
255
- });
260
+ // 服務端:pickFolder 內部是 SHBrowseForFolder,同步阻塞、耗時完全由使用者決定
261
+ const service = { pickFolder: () => shell.pickFolder() };
256
262
 
257
- api.on("windowState", (s) => bar.setMaximized(s.maximized));
263
+ // 頁面端:必須關閉逾時,否則使用者挑久一點,用戶端先 reject,而服務端還在正常阻塞
264
+ const api = await connect<Api, Events>({ timeoutMs: 0 });
258
265
  ```
259
266
 
260
- 宿主端需在 IPC 服務中公開對應的四個視窗控制方法:
267
+ `0` 表示**不逾時**(不是「0 毫秒逾時」)。踩坑的形態有兩種,都不會讓門禁變紅:
268
+
269
+ - 頁面沒接 `.catch` → 拒絕完全靜默,表現為「選完了介面毫無反應」。
270
+ - 使用者按了「取消」很快返回,所以只有**挑久了**才重現,容易被判成偶發。
271
+
272
+ 還有一層要知道:`shell.pickFolder()` **阻塞的是 Bun 主執行緒**。對話方塊開啟期間,
273
+ 同一行程裡的計時器不會觸發,其他 IPC 呼叫也會排隊(不是 reject,是沒有回應)。
274
+ 用戶端那 30 秒計時器跑在瀏覽器渲染行程裡,不受影響——所以調短逾時並不能解決阻塞,
275
+ 只能讓症狀更早暴露。需要併發就得把 `pickFolder` 放進 Worker 裡呼叫。
276
+
277
+ #### 用戶端保留名稱
278
+
279
+ 用戶端是一個 `Proxy`:有幾個名稱會解析到用戶端自己身上,永遠到不了遠端方法。服務表裡若有
280
+ 同名方法,**那個服務端方法就永遠呼叫不到**——頁面呼叫的是用戶端自己的成員,兩端都不會報錯。
281
+
282
+ | 名稱 | 頁面實際拿到的東西 |
283
+ | --- | --- |
284
+ | `on` / `off` | 事件訂閱輔助函式 |
285
+ | `close()` | 關閉 WebSocket 通道——之後所有呼叫都會被 reject |
286
+ | `connected` | `boolean`,通道目前是否可用 |
287
+ | `pending` | `number`,尚未收到回覆的呼叫數 |
288
+ | `then` | `undefined`,否則 `await client` 會把這個永不兌現的 thenable 等下去 |
289
+
290
+ Symbol 鍵同樣保留。給服務方法換個名稱(`winClose` 而不是 `close`),在頁面端映射回去 ——
291
+ 見下方的標題列一節。
292
+
293
+ ### `bun-app-window/titlebar` — Windows 11 風格標題列
294
+
295
+ 用於在無邊框模式下快速掛載符合 Windows 11 視覺規範的自訂標題列。IPC 服務是**扁平的**——
296
+ 沒有 `window` 這一層命名空間——而且四個最自然的名稱裡有一個被用戶端自己佔了,所以宿主端
297
+ 公開 `winClose`,頁面端再映射回去:
261
298
 
262
299
  ```ts
263
- // 宿主端
300
+ // 宿主端 —— 扁平服務;`close` 是 IPC 用戶端的保留名稱
264
301
  createIpcServer({
265
302
  service: {
266
- window: {
267
- minimize: () => shell.minimize(),
268
- toggleMaximize: () => shell.toggleMaximize(),
269
- close: () => shell.close(),
270
- drag: () => shell.drag(),
271
- },
303
+ minimize: () => shell.minimize(),
304
+ toggleMaximize: () => shell.toggleMaximize(),
305
+ winClose: () => shell.close(),
306
+ drag: () => shell.drag(),
272
307
  },
273
308
  });
274
309
  ```
275
310
 
311
+ ```ts
312
+ // 頁面端 —— 明確拼出 bridge
313
+ import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
314
+
315
+ const bar = mountTitleBar({
316
+ title: "我的應用程式",
317
+ icon: "/icon.svg",
318
+ bridge: bridgeFromWindowService({
319
+ minimize: () => api.minimize(),
320
+ toggleMaximize: () => api.toggleMaximize(),
321
+ close: () => api.winClose(), // 橋接時再映射回 close
322
+ drag: () => api.drag(),
323
+ }),
324
+ });
325
+
326
+ api.on("windowState", (s) => bar.setMaximized(s.maximized));
327
+ ```
328
+
276
329
  實作細節:
277
330
 
278
331
  - 原生拖曳體驗:拖曳操作透過呼叫 `shell.drag()` 交由底層發送 `WM_NCLBUTTONDOWN`(`HTCAPTION`)訊息處理,完整保留 Windows 11 貼齊版面配置(Snap Layouts)與連按兩下最大化等系統級行為。
@@ -359,11 +412,14 @@ pump.onGap((missing) => console.warn(`漏了 ${missing} 條事件`));
359
412
  import { debugOf } from "bun-app-window/debug";
360
413
 
361
414
  const d = debugOf(shell);
362
- d.metrics(); // 取得 pos / win / client / work / border / round 等幾何度量
363
- d.dpi(); // 取得目前 DPI 感知層級與縮放係數
415
+ d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
416
+ d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
364
417
  d.hittest(x, y);
365
418
  ```
366
419
 
420
+ `metrics()` 與 `dpi()` 回傳的是**格式化文字**,不是物件。真要解析就寫進一次性指令碼裡,別把
421
+ 解析器塞進正式程式碼——這個格式不屬於任何契約。
422
+
367
423
  ### `bun-app-window/native` — 預編譯動態連結程式庫本體
368
424
 
369
425
  該子路徑解析到 `dist/libshell.dll` 本身,`import.meta.resolve()` 拿到的是可直接複製到 exe 旁邊的真實路徑:
@@ -405,13 +461,10 @@ const shell = await openShell({ url, browserUi: BROWSER_UI_ALL });
405
461
  停用預設瀏覽器選單後,可透過 `showContextMenu` 喚起基於 Win32 `TrackPopupMenu` 繪製的原生選單。頁面無法直接呼叫它——需先在 IPC 服務上暴露:
406
462
 
407
463
  ```ts
408
- // 宿主端:將選單能力掛載到 window 服務
464
+ // 宿主端:將選單能力掛載到扁平服務上
409
465
  createIpcServer({
410
466
  service: {
411
- window: {
412
- showContextMenu: (x: number, y: number, items: string) =>
413
- shell.showContextMenu(x, y, items),
414
- },
467
+ showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
415
468
  },
416
469
  });
417
470
  ```
@@ -420,7 +473,7 @@ createIpcServer({
420
473
  // 頁面端觸發選單
421
474
  document.addEventListener("contextmenu", async (e) => {
422
475
  e.preventDefault();
423
- await api.window.showContextMenu(
476
+ await api.showContextMenu(
424
477
  Math.round(e.screenX),
425
478
  Math.round(e.screenY),
426
479
  "open|開啟\nrefresh|重新整理\n-\nquit|結束"
@@ -490,6 +543,18 @@ bun build --compile --windows-hide-console src/index.ts --outfile dist/app.exe
490
543
  - 僅透過副作用匯入 `import "bun-app-window/embedded";` 同樣生效。
491
544
  - 封裝桌面端應用程式時請務必加上 `--windows-hide-console` 參數,否則啟動時將伴隨彈出黑色主控台(Console)視窗。
492
545
 
546
+ ### 6. 兩個型別檢查抓不到的坑
547
+
548
+ 這兩條都不會讓 `bun check` 變紅,但都會得到一個「渲染正常、什麼也不做」的視窗。
549
+
550
+ - **頁面 bundle 是 ES 模組,`<script>` 標籤必須寫 `type="module"`。** 帶頂層 `await` 的封裝
551
+ 產物塞進傳統 `<script>` 就是語法錯誤,**整個指令碼一行都不執行**——而頁面照樣渲染,因為
552
+ HTML 是靜態的。螢幕上沒有任何東西指向這個原因。
553
+ - **自己的 `data-*` 選擇器要給命名空間。** 標題列的按鈕用
554
+ `data-act="minimize|maximize|close"`,並在 `document` 上掛 click 監聽。宿主如果也綁
555
+ `button[data-act]`,同一次點擊會被接兩遍,然後打出自己那句「未知動作:maximize」。給自己的
556
+ 屬性加前綴(`data-mvp-act`)——泛化屬性名稱是共享命名空間,不是你的地盤。
557
+
493
558
  ## 底層機制與互動參考
494
559
 
495
560
  ### 原生動態連結程式庫載入順序
@@ -549,7 +614,7 @@ bun run demo # 啟動互動式自我檢測範例程式(需於桌面工
549
614
  ## 致謝
550
615
 
551
616
  - 底層跨平台視窗抽象源自 [webview/webview](https://github.com/webview/webview) `0.12.0`(MIT 授權,作者 Serge Zaitsev · Steffen André Langnes)。儲存庫內 `vendor/webview/` 目錄為其原始碼原樣拷貝,未作任何侵入式修改。
552
- - WebView2 C/C++ SDK 源自 [Microsoft.Web.WebView2](https://www.nuget.org/packages/Microsoft.Web.WebView2) `1.0.1150.38`(NuGet 套件隨儲存庫發佈以支援離線建構),執行環境由作業系統提供。
617
+ - WebView2 C/C++ SDK 源自 [Microsoft.Web.WebView2](https://www.nuget.org/packages/Microsoft.Web.WebView2) `1.0.4258.31`(NuGet 套件隨儲存庫發佈以支援離線建構),執行環境由作業系統提供。
553
618
  - Co-authored-by DeepSeek
554
619
 
555
620
  ## 授權條款
package/dist/libshell.dll CHANGED
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bun-app-window",
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "description": "Native WebView2 window shell for Bun — frameless or system-decorated windows, DPI-aware geometry, typed IPC, system-accurate title bar",
6
6
  "type": "module",
7
7
  "license": "MIT",
package/src/shell/app.ts CHANGED
@@ -326,7 +326,24 @@ export async function createApp(opts: AppOptions): Promise<App> {
326
326
  // 等 `EVENT_CLOSED` 而不是轮询 `isAlive()`:CLOSED 由原生侧在 teardown
327
327
  // 全部做完、g_webview 已置空之后写进队列,它出现的时刻就等于「单例已释放」。
328
328
  // 超时上限保留,且超时后也 resolve 一次——否则 `wait()` 会永远挂着。
329
- await Promise.race([closed.promise, Bun.sleep(CLOSE_TIMEOUT_MS)]);
329
+ //
330
+ // 定时器必须可取消:`Promise.race` 只取先兑现的,**不取消输掉的那个**。
331
+ // 早先这里写的是 `Bun.sleep(CLOSE_TIMEOUT_MS)`,正常关窗路径下它就变成一个
332
+ // 没人等的、未 unref 的 5 秒定时器,把 Bun 的事件循环撑满——实测
333
+ // `race returned @ 9ms` 之后进程到 5010 ms 才退出,真实关窗 5043 ms。
334
+ // 不用 `Bun.sleep` + `AbortSignal`:Bun 当前版本不支持。
335
+ let cancelTimeout: () => void = () => {};
336
+ const timeout = new Promise<void>((resolve) => {
337
+ const timer = setTimeout(resolve, CLOSE_TIMEOUT_MS);
338
+ cancelTimeout = () => clearTimeout(timer);
339
+ });
340
+ try {
341
+ await Promise.race([closed.promise, timeout]);
342
+ } finally {
343
+ // 判据要落在「进程何时退出」,不是「stop() 何时返回」:
344
+ // 摘掉这一行时 stop() 仍然 42 ms 就返回了,但进程要 5.9 s 才退。
345
+ cancelTimeout();
346
+ }
330
347
  closed.resolve();
331
348
  events.stop();
332
349
  server.stop(true);
@@ -234,7 +234,12 @@ export type TitleBarHandle = {
234
234
  };
235
235
 
236
236
  function resolveDocument(doc?: TitleBarDocument): TitleBarDocument {
237
- const found = doc ?? (globalThis as { document?: TitleBarDocument }).document;
237
+ // 双层断言是必需的,不是笔误:消费者 tsconfig 的 `lib` 含 `DOM` 时,
238
+ // `globalThis.document` 是完整的 `Document`,与本组件的极简结构不重叠,
239
+ // 单层 `as` 会被判成 TS2352「两侧类型不够重叠」。`skipLibCheck` 挡不住——
240
+ // 这是包内 `.ts` 源码,不是 `.d.ts`。本仓 tsconfig 的 lib 只有 ESNext,
241
+ // 这个错在本仓永远复现不出来,判据落在 tests/titlebar.test.ts 的源码断言上。
242
+ const found = doc ?? (globalThis as unknown as { document?: TitleBarDocument }).document;
238
243
  if (!found) {
239
244
  throw new Error("mountTitleBar: no document available; pass options.document");
240
245
  }