bun-app-window 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -2
- package/README.md +147 -5
- package/README.zh-CN.md +130 -5
- package/README.zh-TW.md +131 -5
- package/dist/libshell.dll +0 -0
- package/package.json +3 -1
- package/src/shell/app.ts +344 -0
- package/src/shell/events.ts +297 -0
- package/src/shell/index.ts +37 -0
- package/src/shell/native.ts +4 -2
- package/src/shell/state.ts +115 -21
- package/src/shell/types.ts +18 -0
package/README.zh-TW.md
CHANGED
|
@@ -84,7 +84,8 @@ shell.setTitle("Doing work…");
|
|
|
84
84
|
| `start()` | 建立並啟動視窗,回傳 `{ rc, error }`。呼叫後立即回傳,UI 訊息迴圈執行於獨立原生執行緒 |
|
|
85
85
|
| `stop()` | 銷毀視窗並釋放底層資源,支援冪等重複呼叫 |
|
|
86
86
|
| `show()` | 顯示視窗。常搭配 `FLAG_DEFER_SHOW` 使用,實現在頁面渲染就緒後再展現視窗 |
|
|
87
|
-
| `isAlive()` | 檢測視窗是否存活(註:`state()`
|
|
87
|
+
| `isAlive()` | 檢測視窗是否存活(註:`state()` 無法區分「尚未啟動」與「已銷毀」狀態)。等視窗關閉請用 `pollEvents()` 的 `CLOSED`,別輪詢它 |
|
|
88
|
+
| `pollEvents(max?)` | 批次拉取視窗生命週期事件(`STATE` / `CLOSED`),同步、消費式。詳見下文「視窗事件」 |
|
|
88
89
|
| `state()` | 取得目前視窗狀態:`{ maximized, minimized, focused, dark, raw }` |
|
|
89
90
|
| `lastError()` | 取得最近一次操作失敗的錯誤訊息,無錯誤時回傳空字串 |
|
|
90
91
|
| `setTitle` / `minimize` / `toggleMaximize` / `close` | 基礎視窗控制操作,回傳狀態碼 `rc` |
|
|
@@ -113,11 +114,90 @@ shell.setTitle("Doing work…");
|
|
|
113
114
|
| `FLAG_DEFER_SHOW` | 建立視窗後保持隱藏狀態,等待明確呼叫 `show()` |
|
|
114
115
|
| `FLAG_ACCEPT_DROP` | 允許視窗接收外部檔案或資料夾拖放事件 |
|
|
115
116
|
| `FLAG_START_MAXIMIZED` | 視窗首次顯示即處於最大化狀態,避免從一般尺寸跳變至最大化的中間閃爍畫格 |
|
|
117
|
+
| `FLAG_ALLOW_EXTERNAL_NAV` | 放棄外部導覽攔截——用於本身就是瀏覽器的應用程式 |
|
|
118
|
+
|
|
119
|
+
### 外部導覽預設被攔截
|
|
120
|
+
|
|
121
|
+
未提供 `FLAG_ALLOW_EXTERNAL_NAV` 時,一個跨來源連結再也無法把應用程式導覽走。殼層會取消該次導覽,
|
|
122
|
+
把位址交給系統預設瀏覽器:
|
|
123
|
+
|
|
124
|
+
- **同來源**(啟動位址是 `file://` 等無 origin 的形態時,改為判斷「同文件」,即剝除
|
|
125
|
+
`#fragment` 後與啟動位址逐字相同)留在視窗內導覽,應用程式自身的路由不受影響。
|
|
126
|
+
- **跨來源**會取消導覽並交給系統瀏覽器——但僅限 `http:` / `https:`。`file:`、
|
|
127
|
+
`javascript:`、`ms-*:` 以及自訂 scheme 一律靜默取消,絕不觸及協定處理常式;
|
|
128
|
+
`mailto:` / `tel:` 也**不在**放行清單內。
|
|
129
|
+
- **`window.open` / `target=_blank`** 一律被攔下並外開,即使提供了
|
|
130
|
+
`FLAG_ALLOW_EXTERNAL_NAV` 亦然。那是能力邊界而非策略:殼層僅持有一個頂層視窗
|
|
131
|
+
(原生層是處理序級單例),多開出來的視窗沒有任何宿主 API 能管理它。
|
|
132
|
+
|
|
133
|
+
桌面殼層沒有網址列、沒有上一頁按鈕,也沒有任何宿主 API 能把頁面導覽回來——所以一次誤點
|
|
134
|
+
外部連結就等於應用程式被換成了那個網站。這就是預設值選擇安全一側的原因。
|
|
116
135
|
|
|
117
136
|
## 子路徑模組
|
|
118
137
|
|
|
119
138
|
核心視窗控制之外的擴充能力均拆分於獨立子路徑下,可依需求匯入。
|
|
120
139
|
|
|
140
|
+
### `bun-app-window/app` — 應用程式骨架
|
|
141
|
+
|
|
142
|
+
用這個套件寫第一個應用程式時,業務程式碼可能只有 30 行,但**宿主樣板是 180 行**:挑安全埠、
|
|
143
|
+
產生驗證 token、處理 WebSocket 升級、把 HTML 當字串吐出去、把 `createIpcServer`
|
|
144
|
+
接到 Bun 的 websocket 回呼上、拼視窗旗標、處理 `openShell` 失敗、等視窗關閉
|
|
145
|
+
(`EVENT_CLOSED`)、依序 `stop()` 兩個東西。每一段單看都不難,但它們**只在特定順序下正確**
|
|
146
|
+
(`stop()` 不先等視窗真的銷毀就關服務,下次啟動會撞上 "already started")。
|
|
147
|
+
`app` 把這些收成一次呼叫:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// main.ts
|
|
151
|
+
import { createApp } from "bun-app-window/app";
|
|
152
|
+
import { BROWSER_UI_ALL, FLAG_FRAMELESS } from "bun-app-window";
|
|
153
|
+
import html from "./index.html" with { type: "text" };
|
|
154
|
+
|
|
155
|
+
const app = await createApp({
|
|
156
|
+
html,
|
|
157
|
+
title: "我的應用程式",
|
|
158
|
+
flags: FLAG_FRAMELESS, // 想要自繪標題列就明確加上
|
|
159
|
+
browserUi: BROWSER_UI_ALL,
|
|
160
|
+
service: {
|
|
161
|
+
greet: (name: string) => `hi ${name}`,
|
|
162
|
+
pickFolder: () => app.shell.pickFolder(),
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await app.wait(); // 視窗關閉時 resolve
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// page.ts —— index.html 的 bundle 內
|
|
171
|
+
import { connect } from "bun-app-window/ipc";
|
|
172
|
+
const api = await connect(); // token 從頁面 URL 的 ?t= 取得
|
|
173
|
+
await api.greet("Ada");
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
它替你做的事:
|
|
177
|
+
|
|
178
|
+
- 綁定瀏覽器安全的埠(絕不會是 `ERR_UNSAFE_PORT` 那幾個),並把 HTML 服務出去。
|
|
179
|
+
- 每次執行產生一個 token,並在**升級之前**用它校驗 `/ws` —— 升級之後就沒有機會拒絕了。
|
|
180
|
+
`ipc` 未開啟時 `/ws` 是 `404` 而不是 `403`:403 等於告訴對方「這裡有通道」。
|
|
181
|
+
- 把 `createIpcServer` 接到 websocket 的 `open` / `message` / `close` 上,`ipc.emit()` 才能到頁面。
|
|
182
|
+
- `stop()` 有序且冪等:`shell.stop()` → 等 `EVENT_CLOSED`(帶 5 秒上限)→ `server.stop()`。
|
|
183
|
+
- 起一個視窗事件泵並且只起一個:`app.events`。它同時承擔「等視窗關閉」,
|
|
184
|
+
所以是唯一的事件消費者;`attachPlacementStore` 要用它時傳 `{ events: app.events }`。
|
|
185
|
+
- 啟動失敗不留半啟動狀態:`openShell` 拋錯時,已綁定的埠會被釋放。
|
|
186
|
+
|
|
187
|
+
注意事項:
|
|
188
|
+
|
|
189
|
+
- **預設不帶 `FLAG_FRAMELESS`。** 無邊框視窗沒有系統標題列就拖不動,必須由頁面先掛上
|
|
190
|
+
`mountTitleBar`。預設值選的是「頁面什麼都不做也能用」的那一側。
|
|
191
|
+
- **靜態資源只吃內嵌字串。** `with { type: "text" }` 得到的是編譯期常數:不碰檔案系統、
|
|
192
|
+
不產生暫存檔、`bun build --compile` 後仍然成立。要從磁碟讀東西就寫個 `routes` 函式 ——
|
|
193
|
+
五行,而且路徑解析的問題留在你這邊,不會滲進骨架。
|
|
194
|
+
- **不從主入口匯出。** 它和 `./state`、`./ipc` 一樣是子路徑。它從 `./index` 匯入
|
|
195
|
+
`openShell`;若 `index.ts` 反過來匯出它,就成了循環。
|
|
196
|
+
|
|
197
|
+
**這不是 `Bun.WebView` 的替代品。** 那個是 headless 瀏覽器,做自動化 / 測試 / 擷取 ——
|
|
198
|
+
沒有視窗,而且只能截自己的頁面。本專案畫的是真實桌面視窗:系統標題列、DPI 感知、
|
|
199
|
+
貼齊版面配置、工作列歸屬。兩者不在同一條賽道上。
|
|
200
|
+
|
|
121
201
|
### `bun-app-window/ipc` — 型別化雙向通訊與事件匯流排
|
|
122
202
|
|
|
123
203
|
宿主與前端頁面共用同一份 TypeScript 型別宣告,用戶端透過泛型在編譯期完成方法簽章與事件負載驗證:
|
|
@@ -213,7 +293,7 @@ if (saved) restorePlacement(shell, saved);
|
|
|
213
293
|
|
|
214
294
|
shell.show();
|
|
215
295
|
|
|
216
|
-
const store = attachPlacementStore(shell, FILE); //
|
|
296
|
+
const store = attachPlacementStore(shell, FILE); // 訂閱視窗事件,拖曳停下後寫入一次
|
|
217
297
|
process.on("beforeExit", () => store.flush());
|
|
218
298
|
```
|
|
219
299
|
|
|
@@ -223,7 +303,53 @@ process.on("beforeExit", () => store.flush());
|
|
|
223
303
|
- 跨顯示器不同縮放比例下的 DPI 座標換算。
|
|
224
304
|
- 螢幕拓撲變化(如拔除外接螢幕)後的螢幕外座標越界驗證與修正。
|
|
225
305
|
|
|
226
|
-
|
|
306
|
+
寫入是事件驅動的:收到狀態變化**立刻**取得快照(微秒級),寫入磁碟則攢到停下 300 ms 後
|
|
307
|
+
做一次。視窗靜止時既沒有原生呼叫也沒有磁碟寫入。用 `debounceMs` 調整寫入延遲;用
|
|
308
|
+
`pumpMs` 調整內部事件泵的拉取節奏。
|
|
309
|
+
|
|
310
|
+
若已有事件泵(例如用了 `createApp`),把它傳進來,避免兩個消費者爭搶同一批事件:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
import { createApp } from "bun-app-window/app";
|
|
314
|
+
import { attachPlacementStore } from "bun-app-window/state";
|
|
315
|
+
|
|
316
|
+
const app = await createApp({ html });
|
|
317
|
+
const store = attachPlacementStore(app.shell, FILE, { events: app.events });
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
若無需背景訂閱,亦可直接呼叫 `capturePlacement` 與 `savePlacement` 執行單次手動讀寫。
|
|
321
|
+
|
|
322
|
+
### 視窗事件
|
|
323
|
+
|
|
324
|
+
宿主想知道「視窗狀態變了」,不必輪詢——`pollEvents()` 直接給出事件串流:
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
import { EVENT_CLOSED, EVENT_STATE } from "bun-app-window/events";
|
|
328
|
+
|
|
329
|
+
for (const e of shell.pollEvents()) {
|
|
330
|
+
if (e.type === EVENT_CLOSED) break; // 視窗已銷毀,原生單例已釋放
|
|
331
|
+
if (e.type === EVENT_STATE && e.maximized) { /* ... */ }
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`pollEvents()` 是**消費式**的:讀走的記錄不會再來,所以一個行程只應有一個消費者。
|
|
336
|
+
想要固定節奏與多路訂閱時,用 `./events` 的 `attachEventPump()`:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { EVENT_CLOSED, attachEventPump } from "bun-app-window/events";
|
|
340
|
+
|
|
341
|
+
const pump = attachEventPump(shell, { intervalMs: 16 });
|
|
342
|
+
pump.on((e) => { if (e.type === EVENT_CLOSED) pump.stop(); });
|
|
343
|
+
pump.onGap((missing) => console.warn(`漏了 ${missing} 條事件`));
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
- `STATE` 酬載是**快照**:`width` / `height` / `dpi` 與四位狀態
|
|
347
|
+
(`maximized` / `minimized` / `focused` / `dark`)。
|
|
348
|
+
- `CLOSED` 是終態。它由原生側在 teardown 全部做完、視窗代碼已置空之後寫出,所以
|
|
349
|
+
「看到 `CLOSED`」等價於「可以安全重啟視窗」——這是它優於 `isAlive()` 的地方。
|
|
350
|
+
- 原生側環形佇列容量 128 條,滿時丟最舊。遺失量透過 `seq` 缺口如實上報(`onGap`),
|
|
351
|
+
不會偽裝成「什麼都沒發生」。
|
|
352
|
+
- 事件泵的計時器預設 `unref`(不阻止行程退出)。若靠它等視窗關閉,傳 `keepAlive: true`。
|
|
227
353
|
|
|
228
354
|
### `bun-app-window/debug` — 診斷與除錯介面
|
|
229
355
|
|
|
@@ -399,13 +525,13 @@ bun run scripts/gate.ts --clean # 同上,但在 HEAD 的乾淨副本內
|
|
|
399
525
|
|
|
400
526
|
`-Clean` 重現了託管 runner 的條件:將 `HEAD` clone 到 `%TEMP%`,依 lockfile 安裝相依套件,再於其中執行把關。如此一來,本機產物(`.build/`、`node_modules/`、`vendor/webview2/`)便無法掩蓋缺少的檔案。耗時約兩秒。未提交的變更會被提示並排除於乾淨副本之外——如需驗證工作區現況,請移除 `-Clean`。
|
|
401
527
|
|
|
402
|
-
品質把關分三段:`bun check`、`bun test`,以及消費方驗收(`scripts/verify-pack.ts`)。第三段會把 tarball 打包到 `%TEMP%`,安裝進一個臨時專案,對該專案執行型別檢查,再執行 `scripts/consumer-probe.ts
|
|
528
|
+
品質把關分三段:`bun check`、`bun test`,以及消費方驗收(`scripts/verify-pack.ts`)。第三段會把 tarball 打包到 `%TEMP%`,安裝進一個臨時專案,對該專案執行型別檢查,再執行 `scripts/consumer-probe.ts`——斷言涵蓋全部子路徑、DLL 是否真的從 `node_modules` 被 `dlopen`、以及 `libInfo.source` 是否為 `package`(條數由探針自報)。可離線執行,約 0.4 秒,且預設拒絕:任何未在允許清單登記的檔案被打進套件,把關就會失敗。只有在改動把關腳本本身時才用 `-SkipPack`。
|
|
403
529
|
|
|
404
530
|
底層指令仍可直接呼叫:
|
|
405
531
|
|
|
406
532
|
```bash
|
|
407
533
|
bun check # 執行 TypeScript 型別檢查
|
|
408
|
-
bun test # 執行自動化測試套件(位於 tests
|
|
534
|
+
bun test # 執行自動化測試套件(位於 tests/)
|
|
409
535
|
bun run demo # 啟動互動式自我檢測範例程式(需於桌面工作階段下執行)
|
|
410
536
|
```
|
|
411
537
|
|
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
|
+
"version": "0.4.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",
|
|
@@ -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",
|
package/src/shell/app.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* app.ts — 应用骨架:把「起服务 → 开窗 → 接线 → 等退出」收成一次调用
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* 用这个包做第一个应用时,真正要写的业务代码可能只有 30 行,但**宿主样板**是
|
|
7
|
+
* 另外 180 行:挑安全端口、生成鉴权 token、处理 WebSocket 升级、把 HTML 当字符串
|
|
8
|
+
* 吐出去、把 `createIpcServer` 接到 Bun 的 websocket 回调上、拿窗口 flags、
|
|
9
|
+
* 处理 `openShell` 失败、等窗口关闭(`EVENT_CLOSED`)、按顺序 `stop()` 两次。
|
|
10
|
+
*
|
|
11
|
+
* 每一段单看都不难,但它们**必须按特定顺序且互相依赖**——`stop()` 不先等窗口
|
|
12
|
+
* 销毁就去关服务,下次启动会撞上「进程级单例还在」;WS 升级不在 `fetch` 里做,
|
|
13
|
+
* 鉴权就没有落点。这些顺序关系在 `src/host.ts` 里被验证过一遍,但没有沉淀成
|
|
14
|
+
* 可复用的东西,所以每个消费者都要再踩一次。
|
|
15
|
+
*
|
|
16
|
+
* ## 两个设计选择
|
|
17
|
+
*
|
|
18
|
+
* **不导出到主入口。** 它和 `./state`、`./ipc`、`./titlebar` 一样是可选件,
|
|
19
|
+
* 走独立子路径 `bun-app-window/app`。好处不只是「按需引入」——`app.ts` 从
|
|
20
|
+
* `./index` 导入 `openShell`,如果 `index.ts` 反过来导出 `app`,就成了循环。
|
|
21
|
+
* 单向依赖比运行时安全的循环更值得要。
|
|
22
|
+
*
|
|
23
|
+
* **静态资源只吃内联字符串。** 用 `import html from "./index.html" with { type: "text" }`
|
|
24
|
+
* 得到的是编译期常量:不碰文件系统、不产生临时文件、`bun build --compile` 后仍然成立。
|
|
25
|
+
* 从目录读则要处理 `import.meta.dir` 在打包后指向 `B:\~BUN\root` 的问题
|
|
26
|
+
* (`src/host.ts` 里那段 `PACKED` 判断就是为它存在的)。那 5 行留给消费者自己写,
|
|
27
|
+
* 用 `routes` 就能接。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createIpcServer, type IpcServer, type ServiceMethods } from "./ipc";
|
|
31
|
+
import { EVENT_CLOSED, attachEventPump, type EventPump } from "./events";
|
|
32
|
+
import { openShell, type Shell } from "./index";
|
|
33
|
+
import { serveOnSafePort } from "./port";
|
|
34
|
+
import { FLAG_CENTER, FLAG_RESIZABLE, type ShellOptions } from "./types";
|
|
35
|
+
|
|
36
|
+
/** 骨架起出来的东西。`shell` 是完整的内核对象,骨架只是帮它把周边接好 */
|
|
37
|
+
export type App = {
|
|
38
|
+
/** 内核对象。标题、缩放、全屏、原生菜单、位置持久化都从这里拿 */
|
|
39
|
+
readonly shell: Shell;
|
|
40
|
+
/**
|
|
41
|
+
* 窗口生命周期事件流。骨架替你起的**唯一**一个消费者。
|
|
42
|
+
*
|
|
43
|
+
* `shell.pollEvents()` 是消费式的,一个窗口只能有一个消费者。要用
|
|
44
|
+
* `attachPlacementStore` 时传 `{ events: app.events }`,别再自己 attach
|
|
45
|
+
* 一个泵——两个泵会各自拿到一部分事件。
|
|
46
|
+
*
|
|
47
|
+
* 定时器是 keepAlive 的(不 unref):`wait()` 靠它让进程活到窗口关闭。
|
|
48
|
+
*/
|
|
49
|
+
readonly events: EventPump;
|
|
50
|
+
/** 本地服务实际绑定的端口(已避开浏览器受限端口表) */
|
|
51
|
+
readonly port: number;
|
|
52
|
+
/** 页面 URL:带鉴权 token 的查询参数,直接交给 `openShell` 用的就是它 */
|
|
53
|
+
readonly url: string;
|
|
54
|
+
/** 页面 URL 里的 token。页面侧 `connect()` 默认从这里取 */
|
|
55
|
+
readonly token: string;
|
|
56
|
+
/** 给了 `service` 才有;否则为 null */
|
|
57
|
+
readonly ipc: IpcServer | null;
|
|
58
|
+
/**
|
|
59
|
+
* 停止窗口与本地服务。
|
|
60
|
+
*
|
|
61
|
+
* 顺序是有意的:先 `shell.stop()`,再**等 `EVENT_CLOSED`**,最后才关服务。
|
|
62
|
+
* 原生层是进程级单例,`shell_stop()` 只是投了一个 `WM_QUIT`;抢在前面关服务
|
|
63
|
+
* 或重新 start,会撞上「shell already started」(详见 `pitfalls` 2.4g / 7.1)。
|
|
64
|
+
*
|
|
65
|
+
* 等的是事件不是 `isAlive()`:`CLOSED` 由原生侧在 teardown 全部做完、
|
|
66
|
+
* `g_webview` 已置空之后写进队列,它出现的时刻就等于「单例已释放」。
|
|
67
|
+
* 仍然保留超时上限——事件通道若因故失效,宿主不能被永久挂住。
|
|
68
|
+
*
|
|
69
|
+
* 幂等:重复调用立即返回。
|
|
70
|
+
*/
|
|
71
|
+
stop(): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* 等窗口被关闭(用户点 X,或调用了 `stop()`)。
|
|
74
|
+
*
|
|
75
|
+
* 宿主进程不会因为窗口没了就自动退出——它自己起的本地服务还在跑。所以
|
|
76
|
+
* 「应用该结束了」的判据是收到 `EVENT_CLOSED`,不是任何信号。
|
|
77
|
+
*
|
|
78
|
+
* 这个 promise 靠 `events` 的 keepAlive 定时器撑住事件循环;别在等它的同时
|
|
79
|
+
* 把 `events.stop()` 掉,那样两边都收不到关闭信号。
|
|
80
|
+
*/
|
|
81
|
+
wait(): Promise<void>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type AppOptions = {
|
|
85
|
+
/**
|
|
86
|
+
* 页面 HTML,用 `with { type: "text" }` 内联。
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* import html from "./index.html" with { type: "text" };
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
html: string;
|
|
93
|
+
title?: string;
|
|
94
|
+
width?: number;
|
|
95
|
+
height?: number;
|
|
96
|
+
/**
|
|
97
|
+
* 窗口 flags。缺省 `FLAG_RESIZABLE | FLAG_CENTER`(带边框)。
|
|
98
|
+
*
|
|
99
|
+
* 想要自绘标题栏时显式加 `FLAG_FRAMELESS`——骨架不默认它,因为无边框窗口
|
|
100
|
+
* 没有系统标题栏就**拖不动**,而那需要页面先挂上 `mountTitleBar`。
|
|
101
|
+
* 默认值选「即使页面什么都不做也能用」的那一侧。
|
|
102
|
+
*/
|
|
103
|
+
flags?: number;
|
|
104
|
+
minWidth?: number;
|
|
105
|
+
minHeight?: number;
|
|
106
|
+
/** 关掉哪些浏览器行为,取 `BROWSER_UI_*` 的按位或 */
|
|
107
|
+
browserUi?: number;
|
|
108
|
+
appName?: string;
|
|
109
|
+
nativeLibPath?: string;
|
|
110
|
+
embedded?: () => Promise<Uint8Array | null>;
|
|
111
|
+
/**
|
|
112
|
+
* IPC 服务方法表。给了就自动挂 `/ws`(含 token 校验)。
|
|
113
|
+
*
|
|
114
|
+
* 页面侧:
|
|
115
|
+
*
|
|
116
|
+
* ```ts
|
|
117
|
+
* import { connect } from "bun-app-window/ipc";
|
|
118
|
+
* const api = await connect(); // 自动从 ?t= 取 token
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
service?: ServiceMethods;
|
|
122
|
+
/** IPC 错误里是否回传 stack。默认关闭 */
|
|
123
|
+
exposeStack?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* 额外路由。返回 `undefined` 表示「不归我管」,交回骨架的默认处理
|
|
126
|
+
* (`/` 吐 HTML、`/ws` 处理升级、其余 404)。
|
|
127
|
+
*
|
|
128
|
+
* 从磁盘读静态资源、代理上游 API 这类事情写在这里。
|
|
129
|
+
*/
|
|
130
|
+
routes?: (req: Request) => Response | undefined | Promise<Response | undefined>;
|
|
131
|
+
/** 窗口建好、服务就绪后调用一次 */
|
|
132
|
+
onReady?: (app: App) => void;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** 事件拉取节奏,约一帧 */
|
|
136
|
+
const EVENT_POLL_MS = 16;
|
|
137
|
+
/**
|
|
138
|
+
* 等 `EVENT_CLOSED` 的上限。
|
|
139
|
+
*
|
|
140
|
+
* 实测关闭约 50 ms,5 s 是三个数量级的余量。存在的理由不是「怕关得慢」,
|
|
141
|
+
* 而是「事件通道若因故失效,宿主不能被永久挂住」——超时后照旧关服务。
|
|
142
|
+
*/
|
|
143
|
+
const CLOSE_TIMEOUT_MS = 5000;
|
|
144
|
+
|
|
145
|
+
/** 一个只能 resolve 一次的 promise。等窗口关闭用它,比轮询一个布尔值清楚 */
|
|
146
|
+
function defer(): { promise: Promise<void>; resolve: () => void } {
|
|
147
|
+
let resolve: () => void = () => {};
|
|
148
|
+
const promise = new Promise<void>((r) => {
|
|
149
|
+
resolve = r;
|
|
150
|
+
});
|
|
151
|
+
return { promise, resolve };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 不给 `flags` 时用的默认值:**带边框** + 可缩放 + 居中。
|
|
156
|
+
*
|
|
157
|
+
* 刻意不默认 `FLAG_FRAMELESS`。无边框窗口没有系统标题栏就拖不动,必须由页面先挂上
|
|
158
|
+
* `mountTitleBar` 才能用;把它设成默认值会让「只写了 HTML」的应用一上来就无法拖动。
|
|
159
|
+
* 默认值应当选「页面什么都不做也能用」的那一侧。
|
|
160
|
+
*/
|
|
161
|
+
export const DEFAULT_APP_FLAGS = FLAG_RESIZABLE | FLAG_CENTER;
|
|
162
|
+
|
|
163
|
+
/** 页面 URL。token 放查询参数里,页面侧 `connect()` 默认从这里取 */
|
|
164
|
+
export function buildAppUrl(port: number, token: string): string {
|
|
165
|
+
return `http://127.0.0.1:${port}/?t=${token}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `/ws` 请求的处置结论。
|
|
170
|
+
*
|
|
171
|
+
* 抽成纯函数是因为这是整个骨架里**唯一有分支的判据**,而它有三个容易写反的点:
|
|
172
|
+
* 路径要先比、`ipc` 没开时不能当 403(那是 404)、token 不匹配必须在**升级之前**
|
|
173
|
+
* 拒绝——升级之后就没有机会说不了。
|
|
174
|
+
*
|
|
175
|
+
* token 用普通 `!==` 比较而不是恒定时间比较:它是一次性的本地回环随机值,
|
|
176
|
+
* 不是长期凭据,时序侧信道在这里没有可换取的东西。
|
|
177
|
+
*/
|
|
178
|
+
export type WsDecision = "not-ws" | "ipc-disabled" | "forbidden" | "upgrade";
|
|
179
|
+
|
|
180
|
+
export function decideWs(
|
|
181
|
+
pathname: string,
|
|
182
|
+
presented: string | null,
|
|
183
|
+
expected: string,
|
|
184
|
+
ipcEnabled: boolean,
|
|
185
|
+
): WsDecision {
|
|
186
|
+
if (pathname !== "/ws") {
|
|
187
|
+
return "not-ws";
|
|
188
|
+
}
|
|
189
|
+
if (!ipcEnabled) {
|
|
190
|
+
return "ipc-disabled";
|
|
191
|
+
}
|
|
192
|
+
return presented === expected ? "upgrade" : "forbidden";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 起服务、开窗、接线,返回一个可以 `wait()` 的 App。
|
|
197
|
+
*
|
|
198
|
+
* 失败时抛错,且**不留下半启动状态**——本地服务会被关掉。理由是调用方拿到异常后
|
|
199
|
+
* 唯一合理的动作是退出,而一个已经绑好的端口会让「重试一次」也失败。
|
|
200
|
+
*/
|
|
201
|
+
export async function createApp(opts: AppOptions): Promise<App> {
|
|
202
|
+
if (!opts.html || typeof opts.html !== "string") {
|
|
203
|
+
throw new Error("createApp: html is required (import it with { type: \"text\" })");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const token = crypto.randomUUID();
|
|
207
|
+
const ipc = opts.service
|
|
208
|
+
? createIpcServer({
|
|
209
|
+
service: opts.service,
|
|
210
|
+
...(opts.exposeStack === undefined ? {} : { exposeStack: opts.exposeStack }),
|
|
211
|
+
})
|
|
212
|
+
: null;
|
|
213
|
+
|
|
214
|
+
const server = serveOnSafePort({
|
|
215
|
+
fetch(req, srv) {
|
|
216
|
+
return handle(req, srv);
|
|
217
|
+
},
|
|
218
|
+
websocket: {
|
|
219
|
+
open(ws: unknown) {
|
|
220
|
+
ipc?.attach(ws as { send(d: string): void });
|
|
221
|
+
},
|
|
222
|
+
message(ws: unknown, raw: string | Buffer) {
|
|
223
|
+
void ipc?.handle(ws as { send(d: string): void }, raw);
|
|
224
|
+
},
|
|
225
|
+
close(ws: unknown) {
|
|
226
|
+
ipc?.detach(ws as { send(d: string): void });
|
|
227
|
+
},
|
|
228
|
+
} as never,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
async function handle(req: Request, srv: unknown): Promise<Response | undefined> {
|
|
232
|
+
const url = new URL(req.url);
|
|
233
|
+
|
|
234
|
+
// IPC 通道。校验必须在这里做——升级之后就没有机会拒绝了
|
|
235
|
+
switch (decideWs(url.pathname, url.searchParams.get("t"), token, ipc !== null)) {
|
|
236
|
+
case "ipc-disabled":
|
|
237
|
+
return new Response("ipc not enabled", { status: 404 });
|
|
238
|
+
case "forbidden":
|
|
239
|
+
return new Response(null, { status: 403 });
|
|
240
|
+
case "upgrade": {
|
|
241
|
+
const upgrade = (srv as { upgrade(r: Request, o: unknown): boolean }).upgrade;
|
|
242
|
+
if (upgrade.call(srv, req, { data: {} })) {
|
|
243
|
+
return undefined; // 已升级,不要再返回 Response
|
|
244
|
+
}
|
|
245
|
+
return new Response(null, { status: 400 });
|
|
246
|
+
}
|
|
247
|
+
default:
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 调用方的路由优先,能覆盖 `/`(自绘入口页、SPA fallback 都靠这条)
|
|
252
|
+
const custom = await opts.routes?.(req);
|
|
253
|
+
if (custom) {
|
|
254
|
+
return custom;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (url.pathname === "/") {
|
|
258
|
+
return new Response(opts.html, {
|
|
259
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return new Response("not found", { status: 404 });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// `Bun.serve` 的 `port` 类型是 `number | undefined`。`serveOnSafePort` 已经
|
|
267
|
+
// 确认绑定成功,但这里不能顺手断言:一旦为 undefined,拼出来的 URL 会是
|
|
268
|
+
// `http://127.0.0.1:undefined/?t=...`,而 `openShell` 会把它当合法 URL 交给
|
|
269
|
+
// WebView2,表现为一个空白窗口——比抛错难查得多。
|
|
270
|
+
const port = server.port;
|
|
271
|
+
if (port === undefined) {
|
|
272
|
+
server.stop(true);
|
|
273
|
+
throw new Error("createApp: local server did not report a bound port");
|
|
274
|
+
}
|
|
275
|
+
const url = buildAppUrl(port, token);
|
|
276
|
+
|
|
277
|
+
const shellOpts: ShellOptions = {
|
|
278
|
+
url,
|
|
279
|
+
title: opts.title ?? "bun-app-window",
|
|
280
|
+
width: opts.width ?? 1000,
|
|
281
|
+
height: opts.height ?? 680,
|
|
282
|
+
flags: opts.flags ?? DEFAULT_APP_FLAGS,
|
|
283
|
+
...(opts.minWidth === undefined ? {} : { minWidth: opts.minWidth }),
|
|
284
|
+
...(opts.minHeight === undefined ? {} : { minHeight: opts.minHeight }),
|
|
285
|
+
...(opts.browserUi === undefined ? {} : { browserUi: opts.browserUi }),
|
|
286
|
+
...(opts.appName === undefined ? {} : { appName: opts.appName }),
|
|
287
|
+
...(opts.nativeLibPath === undefined ? {} : { nativeLibPath: opts.nativeLibPath }),
|
|
288
|
+
...(opts.embedded === undefined ? {} : { embedded: opts.embedded }),
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
let shell: Shell;
|
|
292
|
+
try {
|
|
293
|
+
shell = await openShell(shellOpts);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
// 不留半启动状态:窗口没起来,端口也不该继续占着
|
|
296
|
+
server.stop(true);
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let stopped = false;
|
|
301
|
+
|
|
302
|
+
// 窗口事件的唯一消费者。`keepAlive: true` 是 `wait()` 成立的前提:
|
|
303
|
+
// unref 的定时器不撑事件循环,而「等一个永不兑现的 promise」也撑不住——
|
|
304
|
+
// 宿主会在窗口还开着的时候直接退出。
|
|
305
|
+
const events = attachEventPump(shell, { intervalMs: EVENT_POLL_MS, keepAlive: true });
|
|
306
|
+
const closed = defer();
|
|
307
|
+
events.on((e) => {
|
|
308
|
+
if (e.type === EVENT_CLOSED) {
|
|
309
|
+
closed.resolve();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const app: App = {
|
|
314
|
+
shell,
|
|
315
|
+
events,
|
|
316
|
+
port,
|
|
317
|
+
url,
|
|
318
|
+
token,
|
|
319
|
+
ipc,
|
|
320
|
+
async stop(): Promise<void> {
|
|
321
|
+
if (stopped) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
stopped = true;
|
|
325
|
+
shell.stop();
|
|
326
|
+
// 等 `EVENT_CLOSED` 而不是轮询 `isAlive()`:CLOSED 由原生侧在 teardown
|
|
327
|
+
// 全部做完、g_webview 已置空之后写进队列,它出现的时刻就等于「单例已释放」。
|
|
328
|
+
// 超时上限保留,且超时后也 resolve 一次——否则 `wait()` 会永远挂着。
|
|
329
|
+
await Promise.race([closed.promise, Bun.sleep(CLOSE_TIMEOUT_MS)]);
|
|
330
|
+
closed.resolve();
|
|
331
|
+
events.stop();
|
|
332
|
+
server.stop(true);
|
|
333
|
+
},
|
|
334
|
+
async wait(): Promise<void> {
|
|
335
|
+
if (stopped) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
await closed.promise;
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
opts.onReady?.(app);
|
|
343
|
+
return app;
|
|
344
|
+
}
|