bun-app-window 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -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` |
@@ -136,6 +137,79 @@ shell.setTitle("Doing work…");
136
137
 
137
138
  核心窗口控制之外的扩展能力均拆分在独立子路径下,可按需引入。
138
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 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;
159
+
160
+ const app = await createApp({
161
+ html,
162
+ title: "我的应用",
163
+ flags: FLAG_FRAMELESS, // 想要自绘标题栏就显式加
164
+ browserUi: BROWSER_UI_ALL,
165
+ service: {
166
+ greet: (name: string) => `hi ${name}`,
167
+ pickFolder: () => app.shell.pickFolder(),
168
+ },
169
+ });
170
+
171
+ await app.wait(); // 窗口关闭时 resolve
172
+ await app.stop(); // 释放事件泵与服务;幂等,已关闭时立即返回
173
+ ```
174
+
175
+ ```ts
176
+ // page.ts —— index.html 的 bundle 里
177
+ import { connect } from "bun-app-window/ipc";
178
+ const api = await connect(); // token 从页面 URL 的 ?t= 取
179
+ await api.greet("Ada");
180
+ ```
181
+
182
+ 它替你做的事:
183
+
184
+ - 绑定浏览器安全的端口(绝不会是 `ERR_UNSAFE_PORT` 那几个),并把 HTML 服务出去。
185
+ - 每次运行生成一个 token,并在**升级之前**用它校验 `/ws` —— 升级之后就没有机会拒绝了。
186
+ `ipc` 没开时 `/ws` 是 `404` 而不是 `403`:403 等于告诉对方「这里有通道」。
187
+ - 把 `createIpcServer` 接到 websocket 的 `open` / `message` / `close` 上,`ipc.emit()` 才能到页面。
188
+ - `stop()` 有序且幂等:`shell.stop()` → 等 `EVENT_CLOSED`(带 5 秒上限)→ `server.stop()`。
189
+ - 起一个窗口事件泵并且只起一个:`app.events`。它同时承担「等窗口关闭」,
190
+ 所以是唯一的事件消费者;`attachPlacementStore` 要用它时传 `{ events: app.events }`。
191
+ - 启动失败不留半启动状态:`openShell` 抛错时,已绑定的端口会被释放。
192
+
193
+ 注意事项:
194
+
195
+ - **默认不带 `FLAG_FRAMELESS`。** 无边框窗口没有系统标题栏就拖不动,必须由页面先挂上
196
+ `mountTitleBar`。默认值选的是「页面什么都不做也能用」的那一侧。
197
+ - **`wait()` 只负责等。** 它在 `EVENT_CLOSED` 上 resolve,但不会停事件泵、也不会关服务——而
198
+ 事件泵的定时器是 ref 的(`keepAlive: true`),所以停在这一行的进程会在窗口消失后继续挂着。
199
+ 必须跟一句 `stop()`:它幂等,且关闭已被观察到时会立即返回。
200
+ - **静态资源只吃内联字符串。** `with { type: "text" }` 得到的是编译期常量:不碰文件系统、
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
+ 而且路径解析的问题留在你这边,不会渗进骨架。
206
+ - **不从主入口导出。** 它和 `./state`、`./ipc` 一样是子路径。它从 `./index` 导入
207
+ `openShell`;若 `index.ts` 反过来导出它,就成了循环。
208
+
209
+ **这不是 `Bun.WebView` 的替代品。** 那个是 headless 浏览器,做自动化 / 测试 / 抓取 ——
210
+ 没有窗口,而且只能截自己的页面。本项目画的是真实桌面窗口:系统标题栏、DPI 感知、
211
+ 贴边分屏、任务栏归属。两者不在同一条赛道上。
212
+
139
213
  ### `bun-app-window/ipc` — 类型化双向通信与事件总线
140
214
 
141
215
  宿主与前端页面共享同一份 TypeScript 类型声明,客户端通过泛型在编译期完成方法签名与事件负载校验:
@@ -178,39 +252,58 @@ api.on("windowState", (s) => bar.setMaximized(s.maximized));
178
252
 
179
253
  默认情况下,`connect` 会从当前页面 URL 的查询参数 `?t=` 中读取鉴权 Token 并连接至 `/ws`。若需自定义连接方式,可显式传入 `url` 或自定义 `channel` 实例。
180
254
 
255
+ #### 客户端保留名
256
+
257
+ 客户端是一个 `Proxy`:有几个名字会解析到客户端自己身上,永远到不了远端方法。服务表里若有
258
+ 同名方法,**那个服务端方法就永远调不到**——页面调的是客户端自己的成员,两端都不会报错。
259
+
260
+ | 名字 | 页面实际拿到的东西 |
261
+ | --- | --- |
262
+ | `on` / `off` | 事件订阅辅助函数 |
263
+ | `close()` | 关闭 WebSocket 通道——之后所有调用都会被 reject |
264
+ | `connected` | `boolean`,通道当前是否可用 |
265
+ | `pending` | `number`,尚未收到回复的调用数 |
266
+ | `then` | `undefined`,否则 `await client` 会把这个永不兑现的 thenable 等下去 |
267
+
268
+ Symbol 键同样保留。给服务方法换个名字(`winClose` 而不是 `close`),在页面侧映射回去 ——
269
+ 见下面的标题栏一节。
270
+
181
271
  ### `bun-app-window/titlebar` — Windows 11 风格标题栏
182
272
 
183
- 用于在无边框模式下快速挂载符合 Windows 11 视觉规范的自定义标题栏:
273
+ 用于在无边框模式下快速挂载符合 Windows 11 视觉规范的自定义标题栏。IPC 服务是**扁平的**——
274
+ 没有 `window` 这一层命名空间——而且四个最自然的名字里有一个被客户端自己占了,所以宿主端
275
+ 暴露 `winClose`,页面侧再映射回去:
184
276
 
185
277
  ```ts
186
- // 页面端
278
+ // 宿主端 —— 扁平服务;`close` 是 IPC 客户端的保留名
279
+ createIpcServer({
280
+ service: {
281
+ minimize: () => shell.minimize(),
282
+ toggleMaximize: () => shell.toggleMaximize(),
283
+ winClose: () => shell.close(),
284
+ drag: () => shell.drag(),
285
+ },
286
+ });
287
+ ```
288
+
289
+ ```ts
290
+ // 页面端 —— 显式拼出 bridge
187
291
  import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
188
292
 
189
293
  const bar = mountTitleBar({
190
294
  title: "我的应用",
191
295
  icon: "/icon.svg",
192
- bridge: bridgeFromWindowService(api.window),
296
+ bridge: bridgeFromWindowService({
297
+ minimize: () => api.minimize(),
298
+ toggleMaximize: () => api.toggleMaximize(),
299
+ close: () => api.winClose(), // 桥接时再映射回 close
300
+ drag: () => api.drag(),
301
+ }),
193
302
  });
194
303
 
195
304
  api.on("windowState", (s) => bar.setMaximized(s.maximized));
196
305
  ```
197
306
 
198
- 宿主端需在 IPC 服务中暴露对应的四个窗口控制方法:
199
-
200
- ```ts
201
- // 宿主端
202
- createIpcServer({
203
- service: {
204
- window: {
205
- minimize: () => shell.minimize(),
206
- toggleMaximize: () => shell.toggleMaximize(),
207
- close: () => shell.close(),
208
- drag: () => shell.drag(),
209
- },
210
- },
211
- });
212
- ```
213
-
214
307
  实现细节:
215
308
 
216
309
  - 原生拖拽体验:拖拽操作通过调用 `shell.drag()` 交由底层发送 `WM_NCLBUTTONDOWN`(`HTCAPTION`)消息处理,完整保留 Windows 11 贴边分屏(Snap Layouts)与双击最大化等系统级行为。
@@ -231,7 +324,7 @@ if (saved) restorePlacement(shell, saved);
231
324
 
232
325
  shell.show();
233
326
 
234
- const store = attachPlacementStore(shell, FILE); // 定时轮询状态,窗口静止时不触发写盘
327
+ const store = attachPlacementStore(shell, FILE); // 订阅窗口事件,拖动停手后写一次盘
235
328
  process.on("beforeExit", () => store.flush());
236
329
  ```
237
330
 
@@ -241,7 +334,52 @@ process.on("beforeExit", () => store.flush());
241
334
  - 跨显示器不同缩放比下的 DPI 坐标换算。
242
335
  - 屏幕拓扑变化(如拔除副屏)后的屏外坐标越界校验与修正。
243
336
 
244
- 若无需后台轮询,也可直接调用 `capturePlacement` 与 `savePlacement` 执行单次手动读写。
337
+ 写入是事件驱动的:收到状态变化**立刻**取快照(微秒级),写盘攒到停手 300 ms 后做一次。
338
+ 窗口静止时既没有原生调用也没有磁盘写入。用 `debounceMs` 调整写盘延迟;用 `pumpMs`
339
+ 调整内部事件泵的拉取节奏。
340
+
341
+ 若已有事件泵(例如用了 `createApp`),把它传进来,避免两个消费者争抢同一批事件:
342
+
343
+ ```ts
344
+ import { createApp } from "bun-app-window/app";
345
+ import { attachPlacementStore } from "bun-app-window/state";
346
+
347
+ const app = await createApp({ html });
348
+ const store = attachPlacementStore(app.shell, FILE, { events: app.events });
349
+ ```
350
+
351
+ 若无需后台订阅,也可直接调用 `capturePlacement` 与 `savePlacement` 执行单次手动读写。
352
+
353
+ ### 窗口事件
354
+
355
+ 宿主要知道「窗口状态变了」,不必轮询——`pollEvents()` 直接给出事件流:
356
+
357
+ ```ts
358
+ import { EVENT_CLOSED, EVENT_STATE } from "bun-app-window/events";
359
+
360
+ for (const e of shell.pollEvents()) {
361
+ if (e.type === EVENT_CLOSED) break; // 窗口已销毁,原生单例已释放
362
+ if (e.type === EVENT_STATE && e.maximized) { /* ... */ }
363
+ }
364
+ ```
365
+
366
+ `pollEvents()` 是**消费式**的:读走的记录不会再来。所以一个进程只应有一个消费者。
367
+ 想要固定节奏与多路订阅时,用 `./events` 的 `attachEventPump()`:
368
+
369
+ ```ts
370
+ import { EVENT_CLOSED, attachEventPump } from "bun-app-window/events";
371
+
372
+ const pump = attachEventPump(shell, { intervalMs: 16 });
373
+ pump.on((e) => { if (e.type === EVENT_CLOSED) pump.stop(); });
374
+ pump.onGap((missing) => console.warn(`漏了 ${missing} 条事件`));
375
+ ```
376
+
377
+ - `STATE` 载荷是**快照**:`width` / `height` / `dpi` 与四位状态(`maximized` / `minimized` / `focused` / `dark`)。
378
+ - `CLOSED` 是终态。它由原生侧在 teardown 全部做完、窗口句柄已置空之后写出,所以
379
+ 「看到 `CLOSED`」等价于「可以安全重启窗口」——这是它优于 `isAlive()` 的地方。
380
+ - 原生侧环形队列容量 128 条,满时丢最旧。丢失量通过 `seq` 缺口如实上报(`onGap`),
381
+ 不会伪装成「什么都没发生」。
382
+ - 事件泵的定时器默认 `unref`(不阻止进程退出)。若靠它等窗口关闭,传 `keepAlive: true`。
245
383
 
246
384
  ### `bun-app-window/debug` — 诊断与排障接口
247
385
 
@@ -251,11 +389,14 @@ process.on("beforeExit", () => store.flush());
251
389
  import { debugOf } from "bun-app-window/debug";
252
390
 
253
391
  const d = debugOf(shell);
254
- d.metrics(); // 获取 pos / win / client / work / border / round 等几何度量
255
- d.dpi(); // 获取当前 DPI 感知级别与缩放因子
392
+ d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
393
+ d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
256
394
  d.hittest(x, y);
257
395
  ```
258
396
 
397
+ `metrics()` 与 `dpi()` 返回的是**格式化文本**,不是对象。真要解析就写进一次性脚本里,别把解析器
398
+ 塞进生产代码——这个格式不属于任何契约。
399
+
259
400
  ### `bun-app-window/native` — 预编译动态库本体
260
401
 
261
402
  该子路径解析到 `dist/libshell.dll` 本身,`import.meta.resolve()` 拿到的是可直接拷到 exe 旁边的真实路径:
@@ -297,13 +438,10 @@ const shell = await openShell({ url, browserUi: BROWSER_UI_ALL });
297
438
  禁用默认浏览器菜单后,可通过 `showContextMenu` 唤起基于 Win32 `TrackPopupMenu` 绘制的原生菜单。页面无法直接调用它——需要先在 IPC 服务上暴露:
298
439
 
299
440
  ```ts
300
- // 宿主端:把菜单能力挂到 window 服务上
441
+ // 宿主端:把菜单能力挂到扁平服务上
301
442
  createIpcServer({
302
443
  service: {
303
- window: {
304
- showContextMenu: (x: number, y: number, items: string) =>
305
- shell.showContextMenu(x, y, items),
306
- },
444
+ showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
307
445
  },
308
446
  });
309
447
  ```
@@ -312,7 +450,7 @@ createIpcServer({
312
450
  // 页面端触发菜单
313
451
  document.addEventListener("contextmenu", async (e) => {
314
452
  e.preventDefault();
315
- await api.window.showContextMenu(
453
+ await api.showContextMenu(
316
454
  Math.round(e.screenX),
317
455
  Math.round(e.screenY),
318
456
  "open|打开\nrefresh|刷新\n-\nquit|退出"
@@ -382,6 +520,18 @@ bun build --compile --windows-hide-console src/index.ts --outfile dist/app.exe
382
520
  - 仅通过副作用导入 `import "bun-app-window/embedded";` 同样生效。
383
521
  - 打包桌面端应用时请务必添加 `--windows-hide-console` 参数,否则启动时将伴随弹出黑色命令行控制台窗口。
384
522
 
523
+ ### 6. 两个类型检查抓不到的坑
524
+
525
+ 这两条都不会让 `bun check` 变红,但都会得到一个「渲染正常、什么也不做」的窗口。
526
+
527
+ - **页面 bundle 是 ES 模块,`<script>` 标签必须写 `type="module"`。** 带顶层 `await` 的构建
528
+ 产物塞进经典 `<script>` 就是语法错,**整个脚本一行都不执行**——而页面照样渲染,因为 HTML
529
+ 是静态的。屏幕上没有任何东西指向这个原因。
530
+ - **自己的 `data-*` 选择器要给命名空间。** 标题栏的按钮用
531
+ `data-act="minimize|maximize|close"`,并在 `document` 上挂 click 监听。宿主如果也绑
532
+ `button[data-act]`,同一次点击会被接两遍,然后打出自己那句「未知动作:maximize」。给自己的
533
+ 属性加前缀(`data-mvp-act`)——泛化属性名是共享命名空间,不是你的地盘。
534
+
385
535
  ## 底层机制与交互参考
386
536
 
387
537
  ### 原生动态库加载顺序
@@ -417,7 +567,7 @@ bun run scripts/gate.ts --clean # 同上,但在 HEAD 的干净副本里
417
567
 
418
568
  `-Clean` 复现了托管 runner 的条件:把 `HEAD` clone 到 `%TEMP%`,按 lockfile 安装依赖,再在其中跑门禁。这样本地产物(`.build/`、`node_modules/`、`vendor/webview2/`)就无法掩盖缺失的文件。耗时约两秒。未提交改动会被提示并排除在干净副本之外——如需验证工作区现状,请去掉 `-Clean`。
419
569
 
420
- 门禁分三段:`bun check`、`bun test`,以及消费方验收(`scripts/verify-pack.ts`)。第三段会把 tarball 打进 `%TEMP%`,装进一个临时工程,对那个工程做类型检查,再运行 `scripts/consumer-probe.ts`——40 条断言覆盖全部子路径、DLL 是否真从 `node_modules` 里被 `dlopen`、以及 `libInfo.source` 是否为 `package`。离线可跑,约 0.4 秒,且默认拒绝:任何没在允许清单里登记的文件被打进包,门禁就红。只有在改动门禁自身时才用 `-SkipPack`。
570
+ 门禁分三段:`bun check`、`bun test`,以及消费方验收(`scripts/verify-pack.ts`)。第三段会把 tarball 打进 `%TEMP%`,装进一个临时工程,对那个工程做类型检查,再运行 `scripts/consumer-probe.ts`——断言覆盖全部子路径、DLL 是否真从 `node_modules` 里被 `dlopen`、以及 `libInfo.source` 是否为 `package`(条数由探针自报)。离线可跑,约 0.4 秒,且默认拒绝:任何没在允许清单里登记的文件被打进包,门禁就红。只有在改动门禁自身时才用 `-SkipPack`。
421
571
 
422
572
  底层命令仍可直接调用:
423
573
 
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
 
@@ -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` |
@@ -136,6 +137,79 @@ shell.setTitle("Doing work…");
136
137
 
137
138
  核心視窗控制之外的擴充能力均拆分於獨立子路徑下,可依需求匯入。
138
139
 
140
+ ### `bun-app-window/app` — 應用程式骨架
141
+
142
+ 用這個套件寫第一個應用程式時,業務程式碼可能只有 30 行,但**宿主樣板是 180 行**:挑安全埠、
143
+ 產生驗證 token、處理 WebSocket 升級、把 HTML 當字串吐出去、把 `createIpcServer`
144
+ 接到 Bun 的 websocket 回呼上、拼視窗旗標、處理 `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 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;
159
+
160
+ const app = await createApp({
161
+ html,
162
+ title: "我的應用程式",
163
+ flags: FLAG_FRAMELESS, // 想要自繪標題列就明確加上
164
+ browserUi: BROWSER_UI_ALL,
165
+ service: {
166
+ greet: (name: string) => `hi ${name}`,
167
+ pickFolder: () => app.shell.pickFolder(),
168
+ },
169
+ });
170
+
171
+ await app.wait(); // 視窗關閉時 resolve
172
+ await app.stop(); // 釋放事件泵與服務;冪等,已關閉時立即回傳
173
+ ```
174
+
175
+ ```ts
176
+ // page.ts —— index.html 的 bundle 內
177
+ import { connect } from "bun-app-window/ipc";
178
+ const api = await connect(); // token 從頁面 URL 的 ?t= 取得
179
+ await api.greet("Ada");
180
+ ```
181
+
182
+ 它替你做的事:
183
+
184
+ - 綁定瀏覽器安全的埠(絕不會是 `ERR_UNSAFE_PORT` 那幾個),並把 HTML 服務出去。
185
+ - 每次執行產生一個 token,並在**升級之前**用它校驗 `/ws` —— 升級之後就沒有機會拒絕了。
186
+ `ipc` 未開啟時 `/ws` 是 `404` 而不是 `403`:403 等於告訴對方「這裡有通道」。
187
+ - 把 `createIpcServer` 接到 websocket 的 `open` / `message` / `close` 上,`ipc.emit()` 才能到頁面。
188
+ - `stop()` 有序且冪等:`shell.stop()` → 等 `EVENT_CLOSED`(帶 5 秒上限)→ `server.stop()`。
189
+ - 起一個視窗事件泵並且只起一個:`app.events`。它同時承擔「等視窗關閉」,
190
+ 所以是唯一的事件消費者;`attachPlacementStore` 要用它時傳 `{ events: app.events }`。
191
+ - 啟動失敗不留半啟動狀態:`openShell` 拋錯時,已綁定的埠會被釋放。
192
+
193
+ 注意事項:
194
+
195
+ - **預設不帶 `FLAG_FRAMELESS`。** 無邊框視窗沒有系統標題列就拖不動,必須由頁面先掛上
196
+ `mountTitleBar`。預設值選的是「頁面什麼都不做也能用」的那一側。
197
+ - **`wait()` 只負責等。** 它在 `EVENT_CLOSED` 上 resolve,但不會停事件泵、也不會關服務——而
198
+ 事件泵的計時器是 ref 的(`keepAlive: true`),所以停在這一行的行程會在視窗消失後繼續掛著。
199
+ 必須跟一句 `stop()`:它冪等,且關閉已被觀察到時會立即回傳。
200
+ - **靜態資源只吃內嵌字串。** `with { type: "text" }` 得到的是編譯期常數:不碰檔案系統、
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
+ 而且路徑解析的問題留在你這邊,不會滲進骨架。
206
+ - **不從主入口匯出。** 它和 `./state`、`./ipc` 一樣是子路徑。它從 `./index` 匯入
207
+ `openShell`;若 `index.ts` 反過來匯出它,就成了循環。
208
+
209
+ **這不是 `Bun.WebView` 的替代品。** 那個是 headless 瀏覽器,做自動化 / 測試 / 擷取 ——
210
+ 沒有視窗,而且只能截自己的頁面。本專案畫的是真實桌面視窗:系統標題列、DPI 感知、
211
+ 貼齊版面配置、工作列歸屬。兩者不在同一條賽道上。
212
+
139
213
  ### `bun-app-window/ipc` — 型別化雙向通訊與事件匯流排
140
214
 
141
215
  宿主與前端頁面共用同一份 TypeScript 型別宣告,用戶端透過泛型在編譯期完成方法簽章與事件負載驗證:
@@ -178,39 +252,58 @@ api.on("windowState", (s) => bar.setMaximized(s.maximized));
178
252
 
179
253
  預設情況下,`connect` 會從目前頁面 URL 的查詢參數 `?t=` 中讀取驗證 Token 並連線至 `/ws`。若需自訂連線方式,可明確傳入 `url` 或自訂 `channel` 實例。
180
254
 
255
+ #### 用戶端保留名稱
256
+
257
+ 用戶端是一個 `Proxy`:有幾個名稱會解析到用戶端自己身上,永遠到不了遠端方法。服務表裡若有
258
+ 同名方法,**那個服務端方法就永遠呼叫不到**——頁面呼叫的是用戶端自己的成員,兩端都不會報錯。
259
+
260
+ | 名稱 | 頁面實際拿到的東西 |
261
+ | --- | --- |
262
+ | `on` / `off` | 事件訂閱輔助函式 |
263
+ | `close()` | 關閉 WebSocket 通道——之後所有呼叫都會被 reject |
264
+ | `connected` | `boolean`,通道目前是否可用 |
265
+ | `pending` | `number`,尚未收到回覆的呼叫數 |
266
+ | `then` | `undefined`,否則 `await client` 會把這個永不兌現的 thenable 等下去 |
267
+
268
+ Symbol 鍵同樣保留。給服務方法換個名稱(`winClose` 而不是 `close`),在頁面端映射回去 ——
269
+ 見下方的標題列一節。
270
+
181
271
  ### `bun-app-window/titlebar` — Windows 11 風格標題列
182
272
 
183
- 用於在無邊框模式下快速掛載符合 Windows 11 視覺規範的自訂標題列:
273
+ 用於在無邊框模式下快速掛載符合 Windows 11 視覺規範的自訂標題列。IPC 服務是**扁平的**——
274
+ 沒有 `window` 這一層命名空間——而且四個最自然的名稱裡有一個被用戶端自己佔了,所以宿主端
275
+ 公開 `winClose`,頁面端再映射回去:
184
276
 
185
277
  ```ts
186
- // 頁面端
278
+ // 宿主端 —— 扁平服務;`close` 是 IPC 用戶端的保留名稱
279
+ createIpcServer({
280
+ service: {
281
+ minimize: () => shell.minimize(),
282
+ toggleMaximize: () => shell.toggleMaximize(),
283
+ winClose: () => shell.close(),
284
+ drag: () => shell.drag(),
285
+ },
286
+ });
287
+ ```
288
+
289
+ ```ts
290
+ // 頁面端 —— 明確拼出 bridge
187
291
  import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
188
292
 
189
293
  const bar = mountTitleBar({
190
294
  title: "我的應用程式",
191
295
  icon: "/icon.svg",
192
- bridge: bridgeFromWindowService(api.window),
296
+ bridge: bridgeFromWindowService({
297
+ minimize: () => api.minimize(),
298
+ toggleMaximize: () => api.toggleMaximize(),
299
+ close: () => api.winClose(), // 橋接時再映射回 close
300
+ drag: () => api.drag(),
301
+ }),
193
302
  });
194
303
 
195
304
  api.on("windowState", (s) => bar.setMaximized(s.maximized));
196
305
  ```
197
306
 
198
- 宿主端需在 IPC 服務中公開對應的四個視窗控制方法:
199
-
200
- ```ts
201
- // 宿主端
202
- createIpcServer({
203
- service: {
204
- window: {
205
- minimize: () => shell.minimize(),
206
- toggleMaximize: () => shell.toggleMaximize(),
207
- close: () => shell.close(),
208
- drag: () => shell.drag(),
209
- },
210
- },
211
- });
212
- ```
213
-
214
307
  實作細節:
215
308
 
216
309
  - 原生拖曳體驗:拖曳操作透過呼叫 `shell.drag()` 交由底層發送 `WM_NCLBUTTONDOWN`(`HTCAPTION`)訊息處理,完整保留 Windows 11 貼齊版面配置(Snap Layouts)與連按兩下最大化等系統級行為。
@@ -231,7 +324,7 @@ if (saved) restorePlacement(shell, saved);
231
324
 
232
325
  shell.show();
233
326
 
234
- const store = attachPlacementStore(shell, FILE); // 定時輪詢狀態,視窗靜止時不觸發寫入磁碟
327
+ const store = attachPlacementStore(shell, FILE); // 訂閱視窗事件,拖曳停下後寫入一次
235
328
  process.on("beforeExit", () => store.flush());
236
329
  ```
237
330
 
@@ -241,7 +334,53 @@ process.on("beforeExit", () => store.flush());
241
334
  - 跨顯示器不同縮放比例下的 DPI 座標換算。
242
335
  - 螢幕拓撲變化(如拔除外接螢幕)後的螢幕外座標越界驗證與修正。
243
336
 
244
- 若無需背景輪詢,亦可直接呼叫 `capturePlacement` 與 `savePlacement` 執行單次手動讀寫。
337
+ 寫入是事件驅動的:收到狀態變化**立刻**取得快照(微秒級),寫入磁碟則攢到停下 300 ms 後
338
+ 做一次。視窗靜止時既沒有原生呼叫也沒有磁碟寫入。用 `debounceMs` 調整寫入延遲;用
339
+ `pumpMs` 調整內部事件泵的拉取節奏。
340
+
341
+ 若已有事件泵(例如用了 `createApp`),把它傳進來,避免兩個消費者爭搶同一批事件:
342
+
343
+ ```ts
344
+ import { createApp } from "bun-app-window/app";
345
+ import { attachPlacementStore } from "bun-app-window/state";
346
+
347
+ const app = await createApp({ html });
348
+ const store = attachPlacementStore(app.shell, FILE, { events: app.events });
349
+ ```
350
+
351
+ 若無需背景訂閱,亦可直接呼叫 `capturePlacement` 與 `savePlacement` 執行單次手動讀寫。
352
+
353
+ ### 視窗事件
354
+
355
+ 宿主想知道「視窗狀態變了」,不必輪詢——`pollEvents()` 直接給出事件串流:
356
+
357
+ ```ts
358
+ import { EVENT_CLOSED, EVENT_STATE } from "bun-app-window/events";
359
+
360
+ for (const e of shell.pollEvents()) {
361
+ if (e.type === EVENT_CLOSED) break; // 視窗已銷毀,原生單例已釋放
362
+ if (e.type === EVENT_STATE && e.maximized) { /* ... */ }
363
+ }
364
+ ```
365
+
366
+ `pollEvents()` 是**消費式**的:讀走的記錄不會再來,所以一個行程只應有一個消費者。
367
+ 想要固定節奏與多路訂閱時,用 `./events` 的 `attachEventPump()`:
368
+
369
+ ```ts
370
+ import { EVENT_CLOSED, attachEventPump } from "bun-app-window/events";
371
+
372
+ const pump = attachEventPump(shell, { intervalMs: 16 });
373
+ pump.on((e) => { if (e.type === EVENT_CLOSED) pump.stop(); });
374
+ pump.onGap((missing) => console.warn(`漏了 ${missing} 條事件`));
375
+ ```
376
+
377
+ - `STATE` 酬載是**快照**:`width` / `height` / `dpi` 與四位狀態
378
+ (`maximized` / `minimized` / `focused` / `dark`)。
379
+ - `CLOSED` 是終態。它由原生側在 teardown 全部做完、視窗代碼已置空之後寫出,所以
380
+ 「看到 `CLOSED`」等價於「可以安全重啟視窗」——這是它優於 `isAlive()` 的地方。
381
+ - 原生側環形佇列容量 128 條,滿時丟最舊。遺失量透過 `seq` 缺口如實上報(`onGap`),
382
+ 不會偽裝成「什麼都沒發生」。
383
+ - 事件泵的計時器預設 `unref`(不阻止行程退出)。若靠它等視窗關閉,傳 `keepAlive: true`。
245
384
 
246
385
  ### `bun-app-window/debug` — 診斷與除錯介面
247
386
 
@@ -251,11 +390,14 @@ process.on("beforeExit", () => store.flush());
251
390
  import { debugOf } from "bun-app-window/debug";
252
391
 
253
392
  const d = debugOf(shell);
254
- d.metrics(); // 取得 pos / win / client / work / border / round 等幾何度量
255
- d.dpi(); // 取得目前 DPI 感知層級與縮放係數
393
+ d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
394
+ d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
256
395
  d.hittest(x, y);
257
396
  ```
258
397
 
398
+ `metrics()` 與 `dpi()` 回傳的是**格式化文字**,不是物件。真要解析就寫進一次性指令碼裡,別把
399
+ 解析器塞進正式程式碼——這個格式不屬於任何契約。
400
+
259
401
  ### `bun-app-window/native` — 預編譯動態連結程式庫本體
260
402
 
261
403
  該子路徑解析到 `dist/libshell.dll` 本身,`import.meta.resolve()` 拿到的是可直接複製到 exe 旁邊的真實路徑:
@@ -297,13 +439,10 @@ const shell = await openShell({ url, browserUi: BROWSER_UI_ALL });
297
439
  停用預設瀏覽器選單後,可透過 `showContextMenu` 喚起基於 Win32 `TrackPopupMenu` 繪製的原生選單。頁面無法直接呼叫它——需先在 IPC 服務上暴露:
298
440
 
299
441
  ```ts
300
- // 宿主端:將選單能力掛載到 window 服務
442
+ // 宿主端:將選單能力掛載到扁平服務上
301
443
  createIpcServer({
302
444
  service: {
303
- window: {
304
- showContextMenu: (x: number, y: number, items: string) =>
305
- shell.showContextMenu(x, y, items),
306
- },
445
+ showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
307
446
  },
308
447
  });
309
448
  ```
@@ -312,7 +451,7 @@ createIpcServer({
312
451
  // 頁面端觸發選單
313
452
  document.addEventListener("contextmenu", async (e) => {
314
453
  e.preventDefault();
315
- await api.window.showContextMenu(
454
+ await api.showContextMenu(
316
455
  Math.round(e.screenX),
317
456
  Math.round(e.screenY),
318
457
  "open|開啟\nrefresh|重新整理\n-\nquit|結束"
@@ -382,6 +521,18 @@ bun build --compile --windows-hide-console src/index.ts --outfile dist/app.exe
382
521
  - 僅透過副作用匯入 `import "bun-app-window/embedded";` 同樣生效。
383
522
  - 封裝桌面端應用程式時請務必加上 `--windows-hide-console` 參數,否則啟動時將伴隨彈出黑色主控台(Console)視窗。
384
523
 
524
+ ### 6. 兩個型別檢查抓不到的坑
525
+
526
+ 這兩條都不會讓 `bun check` 變紅,但都會得到一個「渲染正常、什麼也不做」的視窗。
527
+
528
+ - **頁面 bundle 是 ES 模組,`<script>` 標籤必須寫 `type="module"`。** 帶頂層 `await` 的封裝
529
+ 產物塞進傳統 `<script>` 就是語法錯誤,**整個指令碼一行都不執行**——而頁面照樣渲染,因為
530
+ HTML 是靜態的。螢幕上沒有任何東西指向這個原因。
531
+ - **自己的 `data-*` 選擇器要給命名空間。** 標題列的按鈕用
532
+ `data-act="minimize|maximize|close"`,並在 `document` 上掛 click 監聽。宿主如果也綁
533
+ `button[data-act]`,同一次點擊會被接兩遍,然後打出自己那句「未知動作:maximize」。給自己的
534
+ 屬性加前綴(`data-mvp-act`)——泛化屬性名稱是共享命名空間,不是你的地盤。
535
+
385
536
  ## 底層機制與互動參考
386
537
 
387
538
  ### 原生動態連結程式庫載入順序
@@ -417,7 +568,7 @@ bun run scripts/gate.ts --clean # 同上,但在 HEAD 的乾淨副本內
417
568
 
418
569
  `-Clean` 重現了託管 runner 的條件:將 `HEAD` clone 到 `%TEMP%`,依 lockfile 安裝相依套件,再於其中執行把關。如此一來,本機產物(`.build/`、`node_modules/`、`vendor/webview2/`)便無法掩蓋缺少的檔案。耗時約兩秒。未提交的變更會被提示並排除於乾淨副本之外——如需驗證工作區現況,請移除 `-Clean`。
419
570
 
420
- 品質把關分三段:`bun check`、`bun test`,以及消費方驗收(`scripts/verify-pack.ts`)。第三段會把 tarball 打包到 `%TEMP%`,安裝進一個臨時專案,對該專案執行型別檢查,再執行 `scripts/consumer-probe.ts`——40 條斷言涵蓋全部子路徑、DLL 是否真的從 `node_modules` 被 `dlopen`、以及 `libInfo.source` 是否為 `package`。可離線執行,約 0.4 秒,且預設拒絕:任何未在允許清單登記的檔案被打進套件,把關就會失敗。只有在改動把關腳本本身時才用 `-SkipPack`。
571
+ 品質把關分三段:`bun check`、`bun test`,以及消費方驗收(`scripts/verify-pack.ts`)。第三段會把 tarball 打包到 `%TEMP%`,安裝進一個臨時專案,對該專案執行型別檢查,再執行 `scripts/consumer-probe.ts`——斷言涵蓋全部子路徑、DLL 是否真的從 `node_modules` 被 `dlopen`、以及 `libInfo.source` 是否為 `package`(條數由探針自報)。可離線執行,約 0.4 秒,且預設拒絕:任何未在允許清單登記的檔案被打進套件,把關就會失敗。只有在改動把關腳本本身時才用 `-SkipPack`。
421
572
 
422
573
  底層指令仍可直接呼叫:
423
574
 
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.3.0",
4
+ "version": "0.4.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",
@@ -29,6 +29,8 @@
29
29
  ],
30
30
  "exports": {
31
31
  ".": "./src/shell/index.ts",
32
+ "./app": "./src/shell/app.ts",
33
+ "./events": "./src/shell/events.ts",
32
34
  "./embedded": "./src/shell/embedded.ts",
33
35
  "./debug": "./src/shell/debug.ts",
34
36
  "./state": "./src/shell/state.ts",