bun-app-window 0.4.1 → 0.5.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 CHANGED
@@ -4,6 +4,143 @@ 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.1] - 2026-10-21
8
+
9
+ **0.5.0's drag-and-drop fix was wrong and made dropped files be refused outright. This release replaces it, and adds folder support. The public API is unchanged.**
10
+
11
+ ### Fixed
12
+
13
+ - **Dropped files and folders are accepted again, and the page receives absolute paths.** 0.5.0 set `ICoreWebView2Controller4::put_AllowExternalDrop(FALSE)` on the belief that it stepped aside from WebView2's internal `IDropTarget` and let the OLE chain fall through to the parent window's `DragAcceptFiles` handler. **Both halves of that reasoning were wrong**:
14
+
15
+ 1. `DragAcceptFiles` only sets `WS_EX_ACCEPTFILES`; it does **not** register an `IDropTarget`. Reading the `OleDropTargetInterface` window property across the whole chain (`webview` → `webview_widget` → `Chrome_WidgetWin_0` → `Chrome_WidgetWin_1` → `Chrome_RenderWidgetHostHWND` → `Intermediate D3D Window`) returns false for every window. Modern Windows does not fall back to that legacy path during OLE hit-testing.
16
+ 2. `AllowExternalDrop` means *allow external drop*. `FALSE` refuses drops; it does not forward them.
17
+
18
+ The user-visible symptom was a red "not allowed" cursor over the window on every drag, with no drop and no `WM_DROPFILES`.
19
+
20
+ The fix uses the upstream API built for this case. With `FLAG_ACCEPT_DROP` on, the shell injects a script into the page that forwards the dropped objects to the host; the host reads them back with `ICoreWebView2WebMessageReceivedEventArgs2::get_AdditionalObjects` and converts each to an absolute path. **Pages still need no code of their own** — the listener is injected, like `window.__shell.recv`. On a runtime without `EventArgs2` the `QueryInterface` fails and every other web message keeps working.
21
+
22
+ The `WM_DROPFILES` path (`handle_drop`) stays as a fallback and keeps its own probe. Both paths emit the same shape.
23
+
24
+ ### Added
25
+
26
+ - **Folders now work, and `dirs` tells them apart.** A dropped folder arrives as a `FileSystemDirectoryHandle`, but on the host side that handle does **not** survive — measured: the collection holds one entry, `QueryInterface` to both `ICoreWebView2File` and `ICoreWebView2FileSystemHandle` fails, and the item is simply unusable. What *does* work is that a folder also appears in `dataTransfer.files` as an ordinary `File`, whose `get_Path` returns the correct path.
27
+
28
+ So the injected script posts **handles and files together**, the host tries `ICoreWebView2File` first and the handle second, and duplicate paths are collapsed. Because the surviving object is a `File`, `get_Kind` is unavailable — "is this a folder" is decided by `GetFileAttributesW` + `FILE_ATTRIBUTE_DIRECTORY`, the same predicate the `WM_DROPFILES` fallback uses, which is why both paths produce identical output:
29
+
30
+ ```json
31
+ { "t": "drop", "paths": ["<every path>"], "dirs": ["<the subset that are folders>"] }
32
+ ```
33
+
34
+ `paths` keeps every entry in order; `dirs` is a subset and is always present (possibly empty).
35
+
36
+ - **The host now always answers.** Previously, when `AdditionalObjects` yielded no usable path, the handler returned silently — the page waited, the host said nothing, and neither side had a clue. Every drop now produces exactly one `shell-msg`, carrying `ok`, `err`, and the counts `nFile` / `nHandle` / `nNoPath` that made this diagnosis possible in the first place.
37
+
38
+ - **`scripts/probe-drop-live.ts`** — an interactive probe for the half that cannot be synthesised. `AdditionalObjects` only ever contains objects produced by a real drag; page JavaScript cannot fabricate one with a path, and `keybd_event` cannot hold a mouse button across processes. The probe opens a window, waits for one manual drop, and does the judging automatically: the page reports the received payload back over `fetch`, and the probe asserts paths are non-empty, absolute, existing, and classified exactly as the filesystem says. Twelve checks; a failure names *which* of the five gates closed.
39
+
40
+ `--file` and `--dir` are mutually exclusive — one target per run, because a run that asks for both will always fail the one you did not drag. It must run **non-elevated**; the file comment says why and how.
41
+
42
+ ### Note
43
+
44
+ - **This release went through five rounds of "nothing happens".** Each was a genuinely different cause with an identical symptom, and only the last two were in code this release changed. They are recorded as `docs/pitfalls.md` 2.16 and 2.17, and the injected script now carries a marker for each so the next failure names its own stage:
45
+
46
+ | Round | Cause | What made it visible |
47
+ | --- | --- | --- |
48
+ | 1 | The host ran elevated (High integrity) while Explorer runs at Medium; UIPI refuses the drag before any message is sent | Not a single `dragenter` |
49
+ | 2 | `preventDefault()` alone does not accept a drop — `dataTransfer.dropEffect` stayed `none` | `dragover=72` with `drop=0` |
50
+ | 3 | `chrome.webview.postMessage` is *not* the pair of `get_AdditionalObjects`; `File` serialises to `{}`, so the collection was empty while the call succeeded | `drop=1 files=1`, `count=0` |
51
+ | 4 | The probe reported its own post as failed — it fetched the marker 250 ms later, after the host had already closed the window | the only red check in an otherwise green run |
52
+ | 5 | A folder's `FileSystemHandle` does not survive the bridge; only the `File` in `dataTransfer.files` carries a path | `nHandle=0` while the collection was non-empty |
53
+
54
+ Rounds 3 and 5 are the same lesson twice: **a call that does not throw is not a call that worked.** `__shellDropReady` / `__shellDropPosted` / `__shellDropVia` / `__shellDropError` now exist so "did the injection run", "which route was posted", "was the drop delivered" and "did the host receive a usable object" are four separate observations instead of one silent guess.
55
+
56
+ ### Documentation
57
+
58
+ - `docs/pitfalls.md` gains 2.15 (`AllowExternalDrop(FALSE)` refuses drops), 2.16 (the four gates, with the measured reading at each) and 2.17 (folder handles do not survive); 2.14 now points forward at them instead of recommending the wrong fix.
59
+ - `AGENTS.md` replaces the "step aside from WebView2's `IDropTarget`" rule with the `AdditionalObjects` route, states the file/folder asymmetry and the always-answer rule, and registers the new probe.
60
+ - Three READMEs state that files **and folders** arrive with absolute paths, show how to tell them apart with `dirs`, and confirm the page needs no `drop` handler of its own.
61
+ - `tests/capabilities.test.ts` pins all four gates plus the folder route statically, including that `chrome.webview.postMessage(` must not reappear.
62
+
63
+ ### Verification
64
+
65
+ | Command | Result |
66
+ | --- | --- |
67
+ | `bun check` | 69 files |
68
+ | `bun test` | 558 pass / 0 fail |
69
+ | `bun run scripts/gate.ts` | PASSED |
70
+ | `bun run scripts/verify-dll.ts` | PASSED, same source as `native/shell.cc` (1,016,832 B) |
71
+ | `scripts/probe-window-events.ts` | PASSED (21 checks) |
72
+ | `scripts/probe-accelerator-keys.ts` | PASSED (6 checks) |
73
+ | `scripts/probe-drop.ts` | PASSED (6 checks) |
74
+ | `scripts/probe-drop-live.ts --file` | **PASSED (12 checks)** — one real file drag, absolute path |
75
+ | `scripts/probe-drop-live.ts --dir` | **PASSED (12 checks)** — one real folder drag, path **and** folder classification |
76
+
77
+ ## [0.5.0] - 2026-10-21
78
+
79
+ **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.
80
+
81
+ ### Fixed
82
+
83
+ - **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`.
84
+
85
+ The bug is easy to misdiagnose, because `PostMessage(hwnd, WM_KEYDOWN, VK_F11, 0)` toggles fullscreen correctly — that experiment simply bypasses what broke.
86
+
87
+ 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)`.
88
+
89
+ - **`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*.
90
+
91
+ 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.
92
+
93
+ - **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).
94
+
95
+ 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.
96
+
97
+ - **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.
98
+
99
+ Adding the side-effect import fixes it. Measured in an empty directory with only the `.exe`, after deleting `%LOCALAPPDATA%\BunAppWindow\runtime\`:
100
+
101
+ ```text
102
+ ExitCode 0
103
+ [lib] source=embedded path=...\BunAppWindow\runtime\libshell-15ef6452e3fcc26f.dll
104
+ ```
105
+
106
+ 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.
107
+
108
+ ### Changed
109
+
110
+ - **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.
111
+
112
+ `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.
113
+
114
+ - **`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`).
115
+
116
+ 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.
117
+
118
+ ### Documentation
119
+
120
+ - `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.
121
+ - `AGENTS.md` records both routing mechanisms as implementation rules and replaces the open question about minimum runtime with the compatibility decision above.
122
+ - Three READMEs gain the `timeoutMs` section and cross-reference it from the `pickFolder` row.
123
+
124
+ ### Verification
125
+
126
+ | Command | Result |
127
+ | --- | --- |
128
+ | `bun check` | 68 files |
129
+ | `bun test` | 556 pass / 0 fail |
130
+ | `bun run scripts/gate.ts` | 4 stages, PASSED |
131
+ | `bun run scripts/gate.ts --clean` | PASSED (clean) |
132
+ | `bun run scripts/verify-dll.ts` | PASSED, `dist/libshell.dll` matches `native/shell.cc` |
133
+ | `scripts/probe-window-events.ts` | 21 checks |
134
+ | `scripts/probe-accelerator-keys.ts` | 6 checks |
135
+ | `scripts/probe-drop.ts` | 6 checks |
136
+ | shape B in an empty directory | exit 0, `source=embedded`, `reused=true` |
137
+
138
+ 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.
139
+
140
+ ### Still manual
141
+
142
+ 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.
143
+
7
144
  ## [0.4.1] - 2026-10-21
8
145
 
9
146
  ### Fixed
@@ -327,7 +464,9 @@ First public release.
327
464
  - Not yet verified in managed environments that enforce AppLocker, WDAC, or third-party EDR behaviour blocking.
328
465
  - No commercially trusted code signing: only a self-signed certificate has been exercised end to end.
329
466
 
330
- [Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.1...HEAD
467
+ [Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.5.1...HEAD
468
+ [0.5.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.5.0...v0.5.1
469
+ [0.5.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.1...v0.5.0
331
470
  [0.4.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.0...v0.4.1
332
471
  [0.4.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.3.0...v0.4.0
333
472
  [0.3.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.2...v0.3.0
package/README.md CHANGED
@@ -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
 
@@ -263,6 +263,25 @@ api.on("windowState", (s) => bar.setMaximized(s.maximized));
263
263
 
264
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.
265
265
 
266
+ #### `timeoutMs` — long-running human interaction must opt out
267
+
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:
269
+
270
+ ```ts
271
+ // Host: pickFolder wraps SHBrowseForFolder — synchronous, and however long the user takes
272
+ const service = { pickFolder: () => shell.pickFolder() };
273
+
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 });
276
+ ```
277
+
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
+
266
285
  #### Reserved client names
267
286
 
268
287
  The client is a `Proxy`: a handful of names resolve to the client itself and never reach a remote
@@ -503,6 +522,17 @@ window.addEventListener("shell-msg", (e) => {
503
522
  });
504
523
  ```
505
524
 
525
+ Files **and folders** are forwarded by a script the shell injects, so the page does **not** need its own `drop` handler. Every entry is an **absolute path** (from `ICoreWebView2File::get_Path`), and `dirs` tells you which of them are directories:
526
+
527
+ ```js
528
+ window.addEventListener("shell-msg", (e) => {
529
+ if (e.detail.t !== "drop") return;
530
+ for (const p of e.detail.paths) {
531
+ console.log(e.detail.dirs.includes(p) ? "folder" : "file", p);
532
+ }
533
+ });
534
+ ```
535
+
506
536
  When running in an elevated administrator process (High Integrity Level) while Explorer runs at Medium Integrity Level, the shell automatically whitelists drop messages across UIPI via `ChangeWindowMessageFilterEx`.
507
537
 
508
538
  ### 4. Recommended Desktop CSS Reset
@@ -614,7 +644,7 @@ For window enumeration (`EnumWindows`) or automated screenshot scripts, filter b
614
644
  ## Acknowledgments
615
645
 
616
646
  - 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.
617
- - 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.
647
+ - 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.
618
648
  - Co-authored-by DeepSeek
619
649
 
620
650
  ## License
package/README.zh-CN.md CHANGED
@@ -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
 
@@ -164,6 +164,7 @@ const app = await createApp({
164
164
  browserUi: BROWSER_UI_ALL,
165
165
  service: {
166
166
  greet: (name: string) => `hi ${name}`,
167
+ // 人机交互的调用耗时不可控:页面侧必须 connect({ timeoutMs: 0 }),见 ipc 一节
167
168
  pickFolder: () => app.shell.pickFolder(),
168
169
  },
169
170
  });
@@ -252,6 +253,28 @@ api.on("windowState", (s) => bar.setMaximized(s.maximized));
252
253
 
253
254
  默认情况下,`connect` 会从当前页面 URL 的查询参数 `?t=` 中读取鉴权 Token 并连接至 `/ws`。若需自定义连接方式,可显式传入 `url` 或自定义 `channel` 实例。
254
255
 
256
+ #### `timeoutMs` —— 长时人机交互必须显式关掉
257
+
258
+ 单次调用默认 **30 秒**超时。这对普通方法合理,对**人机交互**是致命的:
259
+
260
+ ```ts
261
+ // 服务端:pickFolder 内部是 SHBrowseForFolder,同步阻塞、耗时完全由用户决定
262
+ const service = { pickFolder: () => shell.pickFolder() };
263
+
264
+ // 页面端:必须关掉超时,否则用户挑久一点,客户端先 reject,而服务端还在正常阻塞
265
+ const api = await connect<Api, Events>({ timeoutMs: 0 });
266
+ ```
267
+
268
+ `0` 表示**不超时**(不是「0 毫秒超时」)。踩坑的形态有两种,都不会让门禁变红:
269
+
270
+ - 页面没接 `.catch` → 拒绝完全静默,表现为「选完了界面毫无反应」。
271
+ - 用户点了「取消」很快返回,所以只有**挑久了**才复现,容易被判成偶发。
272
+
273
+ 还有一层要知道:`shell.pickFolder()` **阻塞的是 Bun 主线程**。对话框打开期间,
274
+ 同一进程里的定时器不会触发,其他 IPC 调用也会排队(不是 reject,是没有响应)。
275
+ 客户端那 30 秒定时器跑在浏览器渲染进程里,不受影响——所以调短超时并不能解决阻塞,
276
+ 只能让症状更早暴露。需要并发就得把 `pickFolder` 放进 Worker 里调。
277
+
255
278
  #### 客户端保留名
256
279
 
257
280
  客户端是一个 `Proxy`:有几个名字会解析到客户端自己身上,永远到不了远端方法。服务表里若有
@@ -481,6 +504,17 @@ window.addEventListener("shell-msg", (e) => {
481
504
  });
482
505
  ```
483
506
 
507
+ 落进窗口的**文件与文件夹**都由壳层注入的脚本自动转发,页面**不需要**自己监听 `drop`。每条都是**绝对路径**(来源 `ICoreWebView2File::get_Path`),而 `dirs` 告诉你其中哪些是目录:
508
+
509
+ ```js
510
+ window.addEventListener("shell-msg", (e) => {
511
+ if (e.detail.t !== "drop") return;
512
+ for (const p of e.detail.paths) {
513
+ console.log(e.detail.dirs.includes(p) ? "文件夹" : "文件", p);
514
+ }
515
+ });
516
+ ```
517
+
484
518
  针对管理员提权场景(High 完整性级别进程接收来自 Medium 级别资源管理器的消息),底层已通过 `ChangeWindowMessageFilterEx` 自动放行 UIPI 消息过滤。
485
519
 
486
520
  ### 4. 前端页面桌面化样式建议
@@ -591,7 +625,7 @@ bun run demo # 启动交互式自检示例程序(需在桌面会话下
591
625
  ## 致谢
592
626
 
593
627
  - 底层跨平台窗口抽象源自 [webview/webview](https://github.com/webview/webview) `0.12.0`(MIT 许可,作者 Serge Zaitsev · Steffen André Langnes)。仓库内 `vendor/webview/` 目录为其源码原样拷贝,未作任何侵入式修改。
594
- - WebView2 C/C++ SDK 源自 [Microsoft.Web.WebView2](https://www.nuget.org/packages/Microsoft.Web.WebView2) `1.0.1150.38`(NuGet 包随仓库分发以支持离线构建),运行时由操作系统提供。
628
+ - WebView2 C/C++ SDK 源自 [Microsoft.Web.WebView2](https://www.nuget.org/packages/Microsoft.Web.WebView2) `1.0.4258.31`(NuGet 包随仓库分发以支持离线构建),运行时由操作系统提供。
595
629
  - Co-authored-by DeepSeek
596
630
 
597
631
  ## 开源许可
package/README.zh-TW.md CHANGED
@@ -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
 
@@ -252,6 +252,28 @@ api.on("windowState", (s) => bar.setMaximized(s.maximized));
252
252
 
253
253
  預設情況下,`connect` 會從目前頁面 URL 的查詢參數 `?t=` 中讀取驗證 Token 並連線至 `/ws`。若需自訂連線方式,可明確傳入 `url` 或自訂 `channel` 實例。
254
254
 
255
+ #### `timeoutMs` —— 長時人機互動必須明確關閉
256
+
257
+ 單次呼叫預設 **30 秒**逾時。這對一般方法合理,對**人機互動**卻是致命的:
258
+
259
+ ```ts
260
+ // 服務端:pickFolder 內部是 SHBrowseForFolder,同步阻塞、耗時完全由使用者決定
261
+ const service = { pickFolder: () => shell.pickFolder() };
262
+
263
+ // 頁面端:必須關閉逾時,否則使用者挑久一點,用戶端先 reject,而服務端還在正常阻塞
264
+ const api = await connect<Api, Events>({ timeoutMs: 0 });
265
+ ```
266
+
267
+ `0` 表示**不逾時**(不是「0 毫秒逾時」)。踩坑的形態有兩種,都不會讓門禁變紅:
268
+
269
+ - 頁面沒接 `.catch` → 拒絕完全靜默,表現為「選完了介面毫無反應」。
270
+ - 使用者按了「取消」很快返回,所以只有**挑久了**才重現,容易被判成偶發。
271
+
272
+ 還有一層要知道:`shell.pickFolder()` **阻塞的是 Bun 主執行緒**。對話方塊開啟期間,
273
+ 同一行程裡的計時器不會觸發,其他 IPC 呼叫也會排隊(不是 reject,是沒有回應)。
274
+ 用戶端那 30 秒計時器跑在瀏覽器渲染行程裡,不受影響——所以調短逾時並不能解決阻塞,
275
+ 只能讓症狀更早暴露。需要併發就得把 `pickFolder` 放進 Worker 裡呼叫。
276
+
255
277
  #### 用戶端保留名稱
256
278
 
257
279
  用戶端是一個 `Proxy`:有幾個名稱會解析到用戶端自己身上,永遠到不了遠端方法。服務表裡若有
@@ -482,6 +504,17 @@ window.addEventListener("shell-msg", (e) => {
482
504
  });
483
505
  ```
484
506
 
507
+ 落進視窗的**檔案與資料夾**都由殼層注入的腳本自動轉發,頁面**不需要**自己監聽 `drop`。每條都是**絕對路徑**(來源 `ICoreWebView2File::get_Path`),而 `dirs` 告訴你其中哪些是資料夾:
508
+
509
+ ```js
510
+ window.addEventListener("shell-msg", (e) => {
511
+ if (e.detail.t !== "drop") return;
512
+ for (const p of e.detail.paths) {
513
+ console.log(e.detail.dirs.includes(p) ? "資料夾" : "檔案", p);
514
+ }
515
+ });
516
+ ```
517
+
485
518
  針對管理員提權場景(High 完整性層級行程接收來自 Medium 層級檔案總管的訊息),底層已透過 `ChangeWindowMessageFilterEx` 自動放行 UIPI 訊息過濾。
486
519
 
487
520
  ### 4. 前端頁面桌面化樣式建議
@@ -592,7 +625,7 @@ bun run demo # 啟動互動式自我檢測範例程式(需於桌面工
592
625
  ## 致謝
593
626
 
594
627
  - 底層跨平台視窗抽象源自 [webview/webview](https://github.com/webview/webview) `0.12.0`(MIT 授權,作者 Serge Zaitsev · Steffen André Langnes)。儲存庫內 `vendor/webview/` 目錄為其原始碼原樣拷貝,未作任何侵入式修改。
595
- - WebView2 C/C++ SDK 源自 [Microsoft.Web.WebView2](https://www.nuget.org/packages/Microsoft.Web.WebView2) `1.0.1150.38`(NuGet 套件隨儲存庫發佈以支援離線建構),執行環境由作業系統提供。
628
+ - WebView2 C/C++ SDK 源自 [Microsoft.Web.WebView2](https://www.nuget.org/packages/Microsoft.Web.WebView2) `1.0.4258.31`(NuGet 套件隨儲存庫發佈以支援離線建構),執行環境由作業系統提供。
596
629
  - Co-authored-by DeepSeek
597
630
 
598
631
  ## 授權條款
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.1",
4
+ "version": "0.5.1",
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);