bun-app-window 0.6.2 → 0.8.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/README.zh-TW.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # bun-app-window
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/bun-app-window?color=cb3837&logo=npm)](https://www.npmjs.com/package/bun-app-window)
4
- [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.4.3-f472b6?logo=bun&logoColor=white)](https://bun.sh)
4
+ [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.4.4-f472b6?logo=bun&logoColor=white)](https://bun.sh)
5
5
  [![TypeScript](https://img.shields.io/badge/TypeScript-%3E%3D7.0.2-3178C6?logo=typescript&logoColor=white)](#系統需求與設計邊界)
6
6
  [![Platform](https://img.shields.io/badge/Platform-Windows%20x64-0078D6?logo=windows&logoColor=white)](#系統需求與設計邊界)
7
7
  [![WebView2](https://img.shields.io/badge/WebView2-System%20Runtime-008080)](#系統需求與設計邊界)
@@ -26,13 +26,13 @@ await openShell({ url: `http://127.0.0.1:${server.port}`, flags: FLAG_FRAMELESS
26
26
 
27
27
  在導入本專案前,請確認以下技術前提與架構取捨是否符合你的應用場景:
28
28
 
29
- | 維度 | 限制與說明 |
30
- | --- | --- |
31
- | 僅支援 Windows x64 | `package.json` 中的 `os` 與 `cpu` 欄位已做強制限制,拒絕在其他平台安裝(套件名稱保持平台中立僅為預留命名空間)。預編譯動態連結程式庫 `libshell.dll` 隨套件發佈,無需在安裝期編譯 |
32
- | 僅支援 Bun 執行環境 | 套件內直接發佈 TypeScript 原始碼,要求 Bun ≥ 1.4.3。Node.js 環境雖可完成套件安裝,但無法直接執行 |
29
+ | 維度 | 限制與說明 |
30
+ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
+ | 僅支援 Windows x64 | `package.json` 中的 `os` 與 `cpu` 欄位已做強制限制,拒絕在其他平台安裝(套件名稱保持平台中立僅為預留命名空間)。預編譯動態連結程式庫 `libshell.dll` 隨套件發佈,無需在安裝期編譯 |
32
+ | 僅支援 Bun 執行環境 | 套件內直接發佈 TypeScript 原始碼,要求 Bun ≥ 1.4.4。Node.js 環境雖可完成套件安裝,但無法直接執行 |
33
33
  | 型別檢查要求 TypeScript ≥ 7.0.2 | API 在 TypeScript 7.0.2 下設計與檢查,`devDependencies` 中精確鎖版;不為更舊的編譯器做相容。高於 7.0.2 的結果未作斷言——`bun-types` 只在那一側才開啟 `typesVersions: ">= 7.1"` 閘控,`with { type: "text" }` 的解析結果會隨之改變 |
34
- | 型別檢查需自備 `@types/bun` | 套件內是 `.ts` 原始碼,消費方的 `bun check` 會一併編譯它們。需執行 `bun add -d @types/bun`,否則型別檢查會在 `node_modules` 內回報 `TS2868: Cannot find name 'Bun'` |
35
- | 基於 `bun:ffi` 建構 | `bun:ffi` 在 Bun 中仍標記為實驗性功能(Experimental)。官方建議正式環境優先採用 Node-API,本專案選擇 `bun:ffi` 是為了換取零第三方相依性的輕量架構 |
34
+ | 型別檢查需自備 `@types/bun` | 套件內是 `.ts` 原始碼,消費方的 `bun check` 會一併編譯它們。需執行 `bun add -d @types/bun`,否則型別檢查會在 `node_modules` 內回報 `TS2868: Cannot find name 'Bun'` |
35
+ | 基於 `bun:ffi` 建構 | `bun:ffi` 在 Bun 中仍標記為實驗性功能(Experimental)。官方建議正式環境優先採用 Node-API,本專案選擇 `bun:ffi` 是為了換取零第三方相依性的輕量架構 |
36
36
 
37
37
  執行環境與能力邊界:
38
38
 
@@ -48,7 +48,7 @@ bun add bun-app-window
48
48
 
49
49
  ### 套件內容包含什麼
50
50
 
51
- 發佈的 tarball 只有預先編譯的 `dist/libshell.dll` 與 TypeScript 原始碼,共 21 個檔案(約 0.4 MB)。原生 C++ 層(`native/`)與上游 vendored 標頭檔(`vendor/`)**不隨套件散布**:它們只在重新編譯 `libshell.dll` 時需要,而重新編譯必須 clone 儲存庫。因此 `bun run build:native` 與 `build:exe` 是本儲存庫專用指令碼,安裝進 `node_modules` 後執行沒有意義。
51
+ 發佈的 tarball 只有預先編譯的 `dist/libshell.dll` 與 TypeScript 原始碼,共 22 個檔案(0.50 MB,解包後 1.47 MB)。原生 C++ 層(`native/`)與上游 vendored 標頭檔(`vendor/`)**不隨套件散布**:它們只在重新編譯 `libshell.dll` 時需要,而重新編譯必須 clone 儲存庫。因此 `bun run build:native` 與 `build:exe` 是本儲存庫專用指令碼,安裝進 `node_modules` 後執行沒有意義。
52
52
 
53
53
  ## 快速開始
54
54
 
@@ -56,22 +56,23 @@ bun add bun-app-window
56
56
  import { openShell, serveOnSafePort, FLAG_FRAMELESS, FLAG_CENTER } from "bun-app-window";
57
57
 
58
58
  const server = serveOnSafePort({
59
- fetch: () => new Response("<h1>Hello</h1>", {
60
- headers: { "content-type": "text/html; charset=utf-8" },
61
- }),
59
+ fetch: () =>
60
+ new Response("<h1>Hello</h1>", {
61
+ headers: { "content-type": "text/html; charset=utf-8" },
62
+ }),
62
63
  });
63
64
 
64
65
  const shell = await openShell({
65
- url: `http://127.0.0.1:${server.port}`,
66
- title: "My App",
67
- width: 1000,
68
- height: 680,
69
- flags: FLAG_FRAMELESS | FLAG_CENTER,
70
- minWidth: 480,
71
- minHeight: 320,
66
+ url: `http://127.0.0.1:${server.port}`,
67
+ title: "My App",
68
+ width: 1000,
69
+ height: 680,
70
+ flags: FLAG_FRAMELESS | FLAG_CENTER,
71
+ minWidth: 480,
72
+ minHeight: 320,
72
73
  });
73
74
 
74
- shell.post(JSON.stringify({ t: "ready" })); // 宿主 → 頁面單向訊息
75
+ shell.post(JSON.stringify({ t: "ready" })); // 宿主 → 頁面單向訊息
75
76
  shell.setTitle("Doing work…");
76
77
  ```
77
78
 
@@ -79,44 +80,49 @@ shell.setTitle("Doing work…");
79
80
 
80
81
  ## 核心 API
81
82
 
83
+ `ackOf(shell, rc)` 把裸狀態碼轉成 `{ rc, err }`,並且**只在失敗時才讀 `lastError()`**——原生錯誤槽不會被成功呼叫清空,成功之後再讀會拿到上一條殘留,看起來像新的失敗。
84
+
82
85
  ### 實例方法與屬性
83
86
 
84
- | 成員 | 說明 |
85
- | --- | --- |
86
- | `start()` | 建立並啟動視窗,回傳 `{ rc, error }`。呼叫後立即回傳,UI 訊息迴圈執行於獨立原生執行緒 |
87
- | `stop()` | 銷毀視窗並釋放底層資源,支援冪等重複呼叫 |
88
- | `show()` | 顯示視窗。常搭配 `FLAG_DEFER_SHOW` 使用,實現在頁面渲染就緒後再展現視窗 |
89
- | `isAlive()` | 檢測視窗是否存活(註:`state()` 無法區分「尚未啟動」與「已銷毀」狀態)。等視窗關閉請用 `pollEvents()` 的 `CLOSED`,別輪詢它 |
90
- | `pollEvents(max?)` | 批次拉取視窗生命週期事件(`STATE` / `CLOSED`),同步、消費式。詳見下文「視窗事件」 |
91
- | `state()` | 取得目前視窗狀態:`{ maximized, minimized, focused, dark, raw }` |
92
- | `lastError()` | 取得最近一次操作失敗的錯誤訊息,無錯誤時回傳空字串 |
93
- | `setTitle` / `minimize` / `toggleMaximize` / `close` | 基礎視窗控制操作,回傳狀態碼 `rc` |
94
- | `setMinSize(w, h)` | 設定視窗最小尺寸(單位:邏輯像素) |
95
- | `drag()` | 觸發原生視窗拖曳(於頁面 `pointerdown` 事件中呼叫,完整保留 Windows 貼齊版面配置特性) |
96
- | `beginResize(edge)` | 觸發原生邊框縮放(於頁面 `pointermove` 事件中呼叫,`edge` 取值見 `RESIZE_EDGE.*`) |
97
- | `post(json)` | 宿主向頁面單向投遞 JSON 訊息,非同步發送且不等待回傳結果 |
98
- | `setZoom(f)` / `getZoom()` | 設定或取得頁面縮放比例(有效範圍 `0.25`–`3.0`,越界值自動鉗制) |
99
- | `reload()` / `openDevTools()` / `toggleFullscreen()` | 頁面重新載入、開啟開發人員工具、切換全螢幕(分別對應 `Ctrl+R` / `F12` / `F11`) |
100
- | `setBrowserUi(mask)` | 停用指定的瀏覽器預設行為,傳入 `BROWSER_UI_*` 旗標的按位元 OR 組合 |
101
- | `showContextMenu(x, y, items)` | 於指定螢幕座標彈出 Win32 原生右鍵快顯功能表(詳見下文用法) |
102
- | `pickFolder(title?)` | 呼叫原生資料夾選擇對話方塊(同步阻塞目前呼叫執行緒;**經 IPC 暴露時務必搭配 `timeoutMs: 0`**,見下文) |
103
- | `libInfo` | 原生動態連結程式庫解析中繼資料(包含實際路徑、載入來源、SHA-256 雜湊值) |
104
- | `symbols` | 底層直通介面:直接存取透過 `dlopen` 匯出的底層 C 符號 |
87
+ | 成員 | 說明 |
88
+ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
89
+ | `start()` | 建立並啟動視窗,回傳 `{ rc, error }`。呼叫後立即回傳,UI 訊息迴圈執行於獨立原生執行緒 |
90
+ | `stop()` | 銷毀視窗並釋放底層資源,支援冪等重複呼叫 |
91
+ | `show()` | 顯示視窗。常搭配 `FLAG_DEFER_SHOW` 使用,實現在頁面渲染就緒後再展現視窗 |
92
+ | `isAlive()` | 檢測視窗是否存活(註:`state()` 無法區分「尚未啟動」與「已銷毀」狀態)。等視窗關閉請用 `pollEvents()` 的 `CLOSED`,別輪詢它 |
93
+ | `pollEvents(max?)` | 批次拉取視窗生命週期事件(`STATE` / `CLOSED`),同步、消費式。詳見下文「視窗事件」 |
94
+ | `state()` | 取得目前視窗狀態:`{ maximized, minimized, focused, dark, raw }` |
95
+ | `lastError()` | 取得最近一次操作失敗的錯誤訊息,無錯誤時回傳空字串 |
96
+ | `setTitle` / `minimize` / `toggleMaximize` / `close` | 基礎視窗控制操作,回傳狀態碼 `rc` |
97
+ | `setMinSize(w, h)` | 設定視窗最小尺寸(單位:邏輯像素) |
98
+ | `drag()` | 觸發原生視窗拖曳(於頁面 `pointerdown` 事件中呼叫,完整保留 Windows 貼齊版面配置特性) |
99
+ | `beginResize(edge)` | 觸發原生邊框縮放(於頁面 `pointermove` 事件中呼叫,`edge` 取值見 `RESIZE_EDGE.*`) |
100
+ | `post(json)` | 宿主向頁面單向投遞 JSON 訊息,非同步發送且不等待回傳結果 |
101
+ | `setZoom(f)` / `getZoom()` | 設定或取得頁面縮放比例(有效範圍 `0.25`–`3.0`,越界值自動鉗制) |
102
+ | `reload()` / `openDevTools()` / `toggleFullscreen()` | 頁面重新載入、開啟開發人員工具、切換全螢幕(分別對應 `Ctrl+R` / `F12` / `F11`) |
103
+ | `setFrameless(frameless)` / `isFrameless()` | 執行時期切換視窗形態並讀取目前形態。只有**建立時**給了 `FLAG_FRAMELESS` 的視窗能切;帶邊框起點的視窗回傳 `rc=-1`,理由在 `lastError()`。詳見下文「視窗旗標」 |
104
+ | `setBrowserUi(mask)` | 停用指定的瀏覽器預設行為,傳入 `BROWSER_UI_*` 旗標的按位元 OR 組合 |
105
+ | `showContextMenu(x, y, items)` | 於指定螢幕座標彈出 Win32 原生右鍵快顯功能表(詳見下文用法) |
106
+ | `pickFolder(title?)` | 呼叫原生資料夾選擇對話方塊(同步阻塞目前呼叫執行緒;**經 IPC 暴露時務必搭配 `timeoutMs: 0`**,見下文) |
107
+ | `libInfo` | 原生動態連結程式庫解析中繼資料(包含實際路徑、載入來源、SHA-256 雜湊值) |
108
+ | `symbols` | 底層直通介面:直接存取透過 `dlopen` 匯出的底層 C 符號 |
105
109
 
106
110
  ### 視窗旗標(Flags)
107
111
 
108
112
  透過按位元 OR(`|`)組合傳入 `opts.flags`:
109
113
 
110
- | 旗標 | 作用說明 |
111
- | --- | --- |
112
- | `FLAG_FRAMELESS` | 無邊框視窗模式,移除系統預設標題列,交由前端頁面自行繪製 |
113
- | `FLAG_RESIZABLE` | 允許使用者調整視窗大小 |
114
- | `FLAG_CENTER` | 啟動時於目前顯示器工作區置中顯示(有邊框與無邊框模式均生效) |
115
- | `FLAG_TOPMOST` | 視窗保持最上層顯示 |
116
- | `FLAG_DEFER_SHOW` | 建立視窗後保持隱藏狀態,等待明確呼叫 `show()` |
117
- | `FLAG_ACCEPT_DROP` | 允許視窗接收外部檔案或資料夾拖放事件 |
118
- | `FLAG_START_MAXIMIZED` | 視窗首次顯示即處於最大化狀態,避免從一般尺寸跳變至最大化的中間閃爍畫格 |
119
- | `FLAG_ALLOW_EXTERNAL_NAV` | 放棄外部導覽攔截——用於本身就是瀏覽器的應用程式 |
114
+ | 旗標 | 作用說明 |
115
+ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
116
+ | `FLAG_FRAMELESS` | 無邊框視窗模式,移除系統預設標題列,交由前端頁面自行繪製。以這種方式建立的視窗可在執行時期用 `setFrameless(false)` 切回帶邊框 |
117
+ | `FLAG_RESIZABLE` | 允許使用者調整視窗大小 |
118
+ | `FLAG_CENTER` | 啟動時於目前顯示器工作區置中顯示(有邊框與無邊框模式均生效) |
119
+ | `FLAG_TOPMOST` | 視窗保持最上層顯示 |
120
+ | `FLAG_DEFER_SHOW` | 建立視窗後保持隱藏狀態,等待明確呼叫 `show()` |
121
+ | `FLAG_ACCEPT_DROP` | 允許視窗接收外部檔案或資料夾拖放事件 |
122
+ | `FLAG_START_MAXIMIZED` | 視窗首次顯示即處於最大化狀態,避免從一般尺寸跳變至最大化的中間閃爍畫格 |
123
+ | `FLAG_ALLOW_EXTERNAL_NAV` | 放棄外部導覽攔截——用於本身就是瀏覽器的應用程式 |
124
+ | `FLAG_TITLEBAR_RESERVE` | 無邊框模式下把網頁內容整體下移一個標題列高度,讓頂端那一條歸原生視窗。**已被 `FLAG_NATIVE_CAPTIONS` 取代**:caption 命中區固定在客戶區 `y ∈ [0, 32)`,這條路會把頁面自繪的標題列放到與點擊真正落點相差 32 px 的位置 |
125
+ | `FLAG_NATIVE_CAPTIONS` | 無邊框模式下,由殼層自己的 layered 子視窗繪製三個 caption 按鈕並在那裡應答 `HTMAXBUTTON`。這是 Snap Layouts 唯一能觸發的途徑:WebView2 的渲染子視窗在另一個處理序裡,恆回 `HTCLIENT`。必須與 `mountTitleBar({ captions: "native" })` 搭配,頁面才會留出 `46 × 3` 而不是自己畫一套 |
120
126
 
121
127
  ### 外部導覽預設被攔截
122
128
 
@@ -160,18 +166,18 @@ import htmlText from "./index.html" with { type: "text" };
160
166
  const html = htmlText as unknown as string;
161
167
 
162
168
  const app = await createApp({
163
- html,
164
- title: "我的應用程式",
165
- flags: FLAG_FRAMELESS, // 想要自繪標題列就明確加上
166
- browserUi: BROWSER_UI_ALL,
167
- service: {
168
- greet: (name: string) => `hi ${name}`,
169
- pickFolder: () => app.shell.pickFolder(),
170
- },
169
+ html,
170
+ title: "我的應用程式",
171
+ flags: FLAG_FRAMELESS, // 想要自繪標題列就明確加上
172
+ browserUi: BROWSER_UI_ALL,
173
+ service: {
174
+ greet: (name: string) => `hi ${name}`,
175
+ pickFolder: () => app.shell.pickFolder(),
176
+ },
171
177
  });
172
178
 
173
- await app.wait(); // 視窗關閉時 resolve
174
- await app.stop(); // 釋放事件泵與服務;冪等,已關閉時立即回傳
179
+ await app.wait(); // 視窗關閉時 resolve
180
+ await app.stop(); // 釋放事件泵與服務;冪等,已關閉時立即回傳
175
181
  ```
176
182
 
177
183
  傳 `page` 就能把打包好的頁面指令碼交給骨架,不必自己寫那條路由。`createApp` 會應答
@@ -187,7 +193,7 @@ const { code } = await bundlePage({ entry: "src/ui/page.ts" });
187
193
  const app = await createApp({ html, page: code, service });
188
194
  ```
189
195
 
190
- `App` 上另有兩個成員,各替我省一次呼叫和一條子路徑匯入:
196
+ `App` 上另有兩個成員,各能省掉一次呼叫和一條子路徑匯入:
191
197
 
192
198
  - `app.debug` —— 與 `debugOf(app.shell)` 回傳同一個物件。同樣不在穩定性承諾範圍內。
193
199
  - `app.attachPlacement(file)` —— 把位置持久化接到骨架的**唯一**事件泵上。自己呼叫
@@ -198,7 +204,7 @@ const app = await createApp({ html, page: code, service });
198
204
  ```ts
199
205
  // page.ts —— index.html 的 bundle 內
200
206
  import { connect } from "bun-app-window/ipc";
201
- const api = await connect(); // token 從頁面 URL 的 ?t= 取得
207
+ const api = await connect(); // token 從頁面 URL 的 ?t= 取得
202
208
  await api.greet("Ada");
203
209
  ```
204
210
 
@@ -211,6 +217,17 @@ await api.greet("Ada");
211
217
  - `stop()` 有序且冪等:`shell.stop()` → 等 `EVENT_CLOSED`(帶 5 秒上限)→ `server.stop()`。
212
218
  - 起一個視窗事件泵並且只起一個:`app.events`。它同時承擔「等視窗關閉」,
213
219
  所以是唯一的事件消費者;`attachPlacementStore` 要用它時傳 `{ events: app.events }`。
220
+ - 把頁面端的例外回傳給宿主。未捕捉例外與未處理的 Promise 拒絕由注入的監聽腳本捕捉,
221
+ 經 HTTP 上報——不走 WebSocket:頁面腳本在求值階段崩潰時通道尚未建立,而那正是最需要
222
+ 觀測的情形。傳 `onPageError` 接收;不傳時輸出到宿主的 stderr,而不是丟棄。
223
+ - 啟動前校驗 `html` 與 `page` 的一致性。只給其中一個屬於設定錯誤,唯一症狀是視窗正常繪製
224
+ 但任何互動都無回應,因此直接拋錯。引用存在但缺少 `type="module"` 時只警告:`<script>`
225
+ 的合法寫法不只一種,字串比對的置信度不足以阻斷啟動。
226
+ - 用 `assets` 服務額外的靜態檔案。編譯型框架通常產出不止一個產物(典型是腳本 + 樣式表),
227
+ `assets` 是一張「路徑 → 文字」的映射,`content-type` 由副檔名推斷。此前每個消費者都要
228
+ 手寫一段 `routes` 回呼來服務樣式表,而最常見的錯法(MIME 型別寫錯)會讓瀏覽器靜默丟棄
229
+ 樣式。未辨識的副檔名回退到 `application/octet-stream` 而不是猜測。呼叫方的 `routes`
230
+ 仍然優先,未登記的路徑回傳 404。
214
231
  - 啟動失敗不留半啟動狀態:`openShell` 拋錯時,已綁定的埠會被釋放。
215
232
 
216
233
  注意事項:
@@ -248,28 +265,45 @@ export type Events = { windowState: { maximized: boolean } };
248
265
  import { createIpcServer } from "bun-app-window/ipc";
249
266
 
250
267
  const ipc = createIpcServer({
251
- service: { greet: (name: string) => `Hello ${name}` },
268
+ service: { greet: (name: string) => `Hello ${name}` },
252
269
  });
253
270
 
254
271
  serveOnSafePort({
255
- fetch(req) { /* 處理靜態頁面與驗證 */ },
256
- websocket: {
257
- open: (ws) => ipc.attach(ws),
258
- message: (ws, raw) => ipc.handle(ws, raw),
259
- close: (ws) => ipc.detach(ws),
260
- },
272
+ fetch(req) {
273
+ /* 處理靜態頁面與驗證 */
274
+ },
275
+ websocket: {
276
+ open: (ws) => ipc.attach(ws),
277
+ message: (ws, raw) => ipc.handle(ws, raw),
278
+ close: (ws) => ipc.detach(ws),
279
+ },
261
280
  });
262
281
 
263
- ipc.emit("windowState", { maximized: false }); // 向頁面主動推播事件
282
+ ipc.emit("windowState", { maximized: false }); // 向頁面主動推播事件
264
283
  ```
265
284
 
285
+ #### 遲連線的狀態投遞
286
+
287
+ 頁面在視窗建立**之後**才載入。宿主從 `createApp` 回傳、到頁面的 WebSocket 建立之間發出的
288
+ 任何事件,都投遞給零個連線,也不會被重播——只從事件讀取初始狀態的頁面會一直是空值,
289
+ 而兩端都不會報錯。
290
+
291
+ 把「狀態類」事件標為黏性,服務端就會在每次連線建立時重播最後一份酬載:
292
+
293
+ ```ts
294
+ ipc.emit("windowState", { maximized: false }, { sticky: true }); // attach() 時重播
295
+ ```
296
+
297
+ 黏性是**明確開啟**的,且每個事件名稱只保留一份酬載,不是歷史佇列。語意上是「通知」而非
298
+ 「狀態」的事件不應開啟:遲到的連線會額外收到一條讀起來像新訊息的舊訊息。
299
+
266
300
  ```ts
267
301
  // 頁面端
268
302
  import { connect } from "bun-app-window/ipc";
269
303
  import type { Api, Events } from "./api";
270
304
 
271
305
  const api = await connect<Api, Events>();
272
- await api.greet("Ada"); // 回傳型別自動推導為 string
306
+ await api.greet("Ada"); // 回傳型別自動推導為 string
273
307
  api.on("windowState", (s) => bar.setMaximized(s.maximized));
274
308
  ```
275
309
 
@@ -302,17 +336,21 @@ const api = await connect<Api, Events>({ timeoutMs: 0 });
302
336
  用戶端是一個 `Proxy`:有幾個名稱會解析到用戶端自己身上,永遠到不了遠端方法。服務表裡若有
303
337
  同名方法,**那個服務端方法就永遠呼叫不到**——頁面呼叫的是用戶端自己的成員,兩端都不會報錯。
304
338
 
305
- | 名稱 | 頁面實際拿到的東西 |
306
- | --- | --- |
307
- | `on` / `off` | 事件訂閱輔助函式 |
308
- | `close()` | 關閉 WebSocket 通道——之後所有呼叫都會被 reject |
309
- | `connected` | `boolean`,通道目前是否可用 |
310
- | `pending` | `number`,尚未收到回覆的呼叫數 |
311
- | `then` | `undefined`,否則 `await client` 會把這個永不兌現的 thenable 等下去 |
339
+ | 名稱 | 頁面實際拿到的東西 |
340
+ | ------------ | ------------------------------------------------------------------- |
341
+ | `on` / `off` | 事件訂閱輔助函式 |
342
+ | `close()` | 關閉 WebSocket 通道——之後所有呼叫都會被 reject |
343
+ | `connected` | `boolean`,通道目前是否可用 |
344
+ | `pending` | `number`,尚未收到回覆的呼叫數 |
345
+ | `then` | `undefined`,否則 `await client` 會把這個永不兌現的 thenable 等下去 |
312
346
 
313
347
  Symbol 鍵同樣保留。給服務方法換個名稱(`winClose` 而不是 `close`),在頁面端映射回去 ——
314
348
  見下方的標題列一節。
315
349
 
350
+ 上述失效在執行期完全不可觀測,因此 `createIpcServer` 在**建立時就會拋錯**:服務表裡出現這些
351
+ 名稱即終止,錯誤訊息指出衝突的鍵並給出替代命名。只有當服務表由外部輸入產生、且已用其他手段
352
+ 驗證過時,才應傳 `{ rejectReservedNames: false }` 關閉本檢查。
353
+
316
354
  ### `bun-app-window/titlebar` — Windows 11 風格標題列
317
355
 
318
356
  用於在無邊框模式下快速掛載符合 Windows 11 視覺規範的自訂標題列。IPC 服務是**扁平的**——
@@ -322,12 +360,12 @@ Symbol 鍵同樣保留。給服務方法換個名稱(`winClose` 而不是 `clo
322
360
  ```ts
323
361
  // 宿主端 —— 扁平服務;`close` 是 IPC 用戶端的保留名稱
324
362
  createIpcServer({
325
- service: {
326
- minimize: () => shell.minimize(),
327
- toggleMaximize: () => shell.toggleMaximize(),
328
- winClose: () => shell.close(),
329
- drag: () => shell.drag(),
330
- },
363
+ service: {
364
+ minimize: () => shell.minimize(),
365
+ toggleMaximize: () => shell.toggleMaximize(),
366
+ winClose: () => shell.close(),
367
+ drag: () => shell.drag(),
368
+ },
331
369
  });
332
370
  ```
333
371
 
@@ -336,14 +374,14 @@ createIpcServer({
336
374
  import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
337
375
 
338
376
  const bar = mountTitleBar({
339
- title: "我的應用程式",
340
- icon: "/icon.svg",
341
- bridge: bridgeFromWindowService({
342
- minimize: () => api.minimize(),
343
- toggleMaximize: () => api.toggleMaximize(),
344
- close: () => api.winClose(), // 橋接時再映射回 close
345
- drag: () => api.drag(),
346
- }),
377
+ title: "我的應用程式",
378
+ icon: "/icon.svg",
379
+ bridge: bridgeFromWindowService({
380
+ minimize: () => api.minimize(),
381
+ toggleMaximize: () => api.toggleMaximize(),
382
+ close: () => api.winClose(), // 橋接時再映射回 close
383
+ drag: () => api.drag(),
384
+ }),
347
385
  });
348
386
 
349
387
  api.on("windowState", (s) => bar.setMaximized(s.maximized));
@@ -352,7 +390,9 @@ api.on("windowState", (s) => bar.setMaximized(s.maximized));
352
390
  實作細節:
353
391
 
354
392
  - 原生拖曳體驗:拖曳操作透過呼叫 `shell.drag()` 交由底層發送 `WM_NCLBUTTONDOWN`(`HTCAPTION`)訊息處理,完整保留 Windows 11 貼齊版面配置(Snap Layouts)與連按兩下最大化等系統級行為。
355
- - 樣式自訂與圖示方案:色彩與尺寸均透過 CSS 變數(`--ws-tb-*`)控制,覆寫變數即可實作佈景主題切換。按鈕圖示預設採用相容性較佳的幾何向量符號;若設定 `icons: "fluent"` 則切換為 `Segoe Fluent Icons` 字型渲染(注意:若目標系統缺少該字型,按鈕將退回顯示為缺字方框)。
393
+ - 樣式自訂:色彩與尺寸均透過 CSS 變數(`--ws-tb-*`)控制,覆寫變數即可實作佈景主題切換。若需自行注入樣式表而不是讓 `mountTitleBar` 注入一次,可單獨呼叫匯出的 `titleBarCss()`。
394
+ - 字形選擇:預設走 `detectIconSet()`,它向 `document.fonts` 詢問 `Segoe Fluent Icons`(Win 11)或 `Segoe MDL2 Assets`(Win 10),有一個在就用 `fluent`——兩套字型的碼位相同,一張表涵蓋兩代系統。只有兩個字型都探測不到時才退回 `unicode` 幾何符號(`─ □ ❐ ✕`)。要覆寫探測結果就明確傳 `icons: "unicode" | "fluent"`。
395
+ - 交給殼層繪製按鈕:傳 `captions: "native"`,元件**一個按鈕都不渲染**,只在右側留出 `46 × 3` 給殼層的 layered 子視窗。必須與 `FLAG_NATIVE_CAPTIONS` 搭配——只開一半的結果是兩套按鈕疊在一起,或者一條點不到的空白區。注意這三個按鈕上的滑鼠點擊走**非客戶區**訊息,所以 `bridge.minimize` / `toggleMaximize` / `close` 在滑鼠這條路上不會被呼叫,動作由殼層自己執行;鍵盤 Tab + Enter 仍然經過 bridge。
356
396
 
357
397
  ### `bun-app-window/state` — 視窗位置與尺寸持久化
358
398
 
@@ -369,7 +409,7 @@ if (saved) restorePlacement(shell, saved);
369
409
 
370
410
  shell.show();
371
411
 
372
- const store = attachPlacementStore(shell, FILE); // 訂閱視窗事件,拖曳停下後寫入一次
412
+ const store = attachPlacementStore(shell, FILE); // 訂閱視窗事件,拖曳停下後寫入一次
373
413
  process.on("beforeExit", () => store.flush());
374
414
  ```
375
415
 
@@ -403,8 +443,10 @@ const store = attachPlacementStore(app.shell, FILE, { events: app.events });
403
443
  import { EVENT_CLOSED, EVENT_STATE } from "bun-app-window/events";
404
444
 
405
445
  for (const e of shell.pollEvents()) {
406
- if (e.type === EVENT_CLOSED) break; // 視窗已銷毀,原生單例已釋放
407
- if (e.type === EVENT_STATE && e.maximized) { /* ... */ }
446
+ if (e.type === EVENT_CLOSED) break; // 視窗已銷毀,原生單例已釋放
447
+ if (e.type === EVENT_STATE && e.maximized) {
448
+ /* ... */
449
+ }
408
450
  }
409
451
  ```
410
452
 
@@ -415,7 +457,9 @@ for (const e of shell.pollEvents()) {
415
457
  import { EVENT_CLOSED, attachEventPump } from "bun-app-window/events";
416
458
 
417
459
  const pump = attachEventPump(shell, { intervalMs: 16 });
418
- pump.on((e) => { if (e.type === EVENT_CLOSED) pump.stop(); });
460
+ pump.on((e) => {
461
+ if (e.type === EVENT_CLOSED) pump.stop();
462
+ });
419
463
  pump.onGap((missing) => console.warn(`漏了 ${missing} 條事件`));
420
464
  ```
421
465
 
@@ -435,8 +479,8 @@ pump.onGap((missing) => console.warn(`漏了 ${missing} 條事件`));
435
479
  import { debugOf } from "bun-app-window/debug";
436
480
 
437
481
  const d = debugOf(shell);
438
- d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
439
- d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
482
+ d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
483
+ d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
440
484
  d.hittest(x, y);
441
485
  ```
442
486
 
@@ -445,8 +489,7 @@ d.hittest(x, y);
445
489
 
446
490
  ### `bun-app-window/types` — 常數面,瀏覽器安全
447
491
 
448
- 一個零依賴模組,承載全部常數:`FLAG_*`、`BROWSER_UI_*`、`RESIZE_EDGE`、`RESIZE_INDEX`、
449
- `ZOOM_LADDER`、`ZOOM_MIN`、`ZOOM_MAX`、`STATE_*` 及相關型別。
492
+ 一個零依賴模組,承載全部常數:`FLAG_*`(十個全在,含 `FLAG_TITLEBAR_RESERVE` 與 `FLAG_NATIVE_CAPTIONS`)、`BROWSER_UI_*`、`RESIZE_EDGE`、`RESIZE_INDEX`、`resizeIndex`、`ZOOM_LADDER`、`ZOOM_MIN`、`ZOOM_MAX`、`STATE_MAXIMIZED` / `STATE_MINIMIZED` / `STATE_FOCUSED`,以及 caption 幾何三件套 `CAPTION_BAR_HEIGHT`(32)/ `CAPTION_BUTTON_WIDTH`(46)/ `CAPTION_BUTTONS`——最後這個的順序是「從右往左 close、maximize、minimize」,而這個順序是有承重的:頁面畫的按鈕順序若與 `caption_button_at()` 的回報順序不一致,點「關閉」會變成最大化。
450
493
 
451
494
  **頁面側請用這個子路徑。** 主入口經 `./native` 引入 `bun:ffi`,瀏覽器打包器解析不了它——
452
495
  所以在頁面程式碼裡寫 `import { BROWSER_UI_ALL } from "bun-app-window"` 會在打包期失敗。
@@ -478,6 +521,56 @@ await writePageBundle({ entry: "src/ui/page.ts", out: "src/ui/page.bundle.txt" }
478
521
  `bytes` 是 UTF-8 位元組數,不是 `string.length`;原始碼裡只要有非 ASCII 文字兩者就不同,
479
522
  而一個「看起來對」的體積讀數比沒有讀數更糟。
480
523
 
524
+ #### 編譯型框架:`plugins`、`define`、`loader`
525
+
526
+ 這三個欄位原樣交給 `Bun.build`,與任何具體框架無關,本套件不自帶任何外掛。
527
+
528
+ ```ts
529
+ const built = await bundlePage({
530
+ entry: "src/main.ts",
531
+ plugins: [myFrameworkPlugin()], // .vue / .svelte / .tsx —— 外掛能編譯什麼就傳什麼
532
+ define: { __VUE_OPTIONS_API__: "false" }, // 編譯期常數
533
+ loader: { ".thing": "ts" }, // 自訂副檔名
534
+ });
535
+ ```
536
+
537
+ #### `alias` 由本套件實作,因為 Bun 沒有這個選項
538
+
539
+ Bun 1.4.4 的 `BuildConfig` 沒有 `alias` 屬性,而且傳進去不會報錯——它被靜默忽略。
540
+ 四種寫法都試過,全部解析失敗。因此本套件用自帶的 `onResolve` 外掛支撐 `alias`:
541
+
542
+ ```ts
543
+ await bundlePage({ entry, alias: { "@": "./src", lib: "./src/lib" } });
544
+ ```
545
+
546
+ | 模組指定符 | 是否重寫 |
547
+ | ------------------------------- | -------- |
548
+ | 與某個鍵相等,或以 `鍵/` 為前綴 | 是 |
549
+ | 以 `.` 或 `/` 開頭 | 否 |
550
+ | 帶 `scheme:` 前綴 | 否 |
551
+
552
+ **預設是空的。** `$lib` 是 SvelteKit 的約定,`@/` 是某些專案自己的 `tsconfig` 別名,
553
+ 兩者都不屬於本套件。別名與呼叫方的外掛是合併關係而非取代,且只在傳入 `alias` 時才註冊該外掛。
554
+
555
+ #### 多產物
556
+
557
+ 編譯型框架經常產出不止一個產物——指令碼加樣式表是常見形態。`outputs` 列出全部,每項帶
558
+ `path` / `kind` / `code` / `bytes`,而 `code` 與 `bytes` 特指 `entry-point` 那一個
559
+ (不是 `outputs[0]`,輸出順序不構成契約)。
560
+
561
+ `writePageBundle` 寫出每一個產物:入口寫到 `out`,其餘寫到同目錄並替換副檔名。
562
+ 所以 `page.bundle.txt` 會得到 `page.bundle.css`,**不是** `main.css`——入口的檔名不該
563
+ 滲進一個 HTML 與 `assets` 會引用的路徑。它回傳 `written: Array<{ path, bytes }>`,
564
+ 便於清理時刪掉自己產生的每一個檔案。
565
+
566
+ ```text
567
+ $ bun run bundle:page
568
+ 109,888 B dist/page.bundle.txt ← 入口產物
569
+ 133 B dist/page.bundle.css ← 附帶產物
570
+ ```
571
+
572
+ 其餘檔案用 `createApp({ assets })` 服務——見上文的 `app` 小節。
573
+
481
574
  失敗時拋錯並帶上 Bun 的完整診斷。兩種形態都覆蓋了:編譯期問題以
482
575
  `{ success: false, logs }` 回傳,而入口本身不可達時**拋 `AggregateError`**,
483
576
  它的 `message` 只有一句 `Bundle failed`,細節在 `.errors` 上。
@@ -508,13 +601,13 @@ const shell = await openShell({ url, browserUi: BROWSER_UI_ALL });
508
601
 
509
602
  亦可依需求組合以下旗標:
510
603
 
511
- | 旗標 | 停用的瀏覽器行為 |
512
- | --- | --- |
513
- | `BROWSER_UI_MENU` | 預設網頁右鍵選單(Edge 快顯功能表) |
514
- | `BROWSER_UI_KEYS` | 瀏覽器內建快速鍵(如 `Ctrl+P` / `Ctrl+F` / `Ctrl+S`) |
515
- | `BROWSER_UI_STATUSBAR` | 左下角連結懸停狀態列 |
516
- | `BROWSER_UI_SWIPE` | 觸控板雙指橫滑上一頁 / 下一頁手勢 |
517
- | `BROWSER_UI_AUTOFILL` | 表單自動填入提示 |
604
+ | 旗標 | 停用的瀏覽器行為 |
605
+ | ---------------------- | ----------------------------------------------------- |
606
+ | `BROWSER_UI_MENU` | 預設網頁右鍵選單(Edge 快顯功能表) |
607
+ | `BROWSER_UI_KEYS` | 瀏覽器內建快速鍵(如 `Ctrl+P` / `Ctrl+F` / `Ctrl+S`) |
608
+ | `BROWSER_UI_STATUSBAR` | 左下角連結懸停狀態列 |
609
+ | `BROWSER_UI_SWIPE` | 觸控板雙指橫滑上一頁 / 下一頁手勢 |
610
+ | `BROWSER_UI_AUTOFILL` | 表單自動填入提示 |
518
611
 
519
612
  註:`F11`、`F12` 及 `Ctrl±` 縮放由視窗外殼層直接接管,不受上述旗標影響。
520
613
 
@@ -525,26 +618,26 @@ const shell = await openShell({ url, browserUi: BROWSER_UI_ALL });
525
618
  ```ts
526
619
  // 宿主端:將選單能力掛載到扁平服務上
527
620
  createIpcServer({
528
- service: {
529
- showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
530
- },
621
+ service: {
622
+ showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
623
+ },
531
624
  });
532
625
  ```
533
626
 
534
627
  ```ts
535
628
  // 頁面端觸發選單
536
629
  document.addEventListener("contextmenu", async (e) => {
537
- e.preventDefault();
538
- await api.showContextMenu(
539
- Math.round(e.screenX),
540
- Math.round(e.screenY),
541
- "open|開啟\nrefresh|重新整理\n-\nquit|結束"
542
- );
630
+ e.preventDefault();
631
+ await api.showContextMenu(
632
+ Math.round(e.screenX),
633
+ Math.round(e.screenY),
634
+ "open|開啟\nrefresh|重新整理\n-\nquit|結束",
635
+ );
543
636
  });
544
637
 
545
638
  // 頁面端監聽選單點擊結果
546
639
  window.addEventListener("shell-msg", (e) => {
547
- if (e.detail.t === "menu") console.log("選取選單項目 ID:", e.detail.id);
640
+ if (e.detail.t === "menu") console.log("選取選單項目 ID:", e.detail.id);
548
641
  });
549
642
  ```
550
643
 
@@ -562,7 +655,7 @@ const shell = await openShell({ url, flags: FLAG_ACCEPT_DROP });
562
655
  ```js
563
656
  // 頁面端接收路徑清單
564
657
  window.addEventListener("shell-msg", (e) => {
565
- if (e.detail.t === "drop") console.log("拖入檔案路徑:", e.detail.paths);
658
+ if (e.detail.t === "drop") console.log("拖入檔案路徑:", e.detail.paths);
566
659
  });
567
660
  ```
568
661
 
@@ -570,10 +663,10 @@ window.addEventListener("shell-msg", (e) => {
570
663
 
571
664
  ```js
572
665
  window.addEventListener("shell-msg", (e) => {
573
- if (e.detail.t !== "drop") return;
574
- for (const p of e.detail.paths) {
575
- console.log(e.detail.dirs.includes(p) ? "資料夾" : "檔案", p);
576
- }
666
+ if (e.detail.t !== "drop") return;
667
+ for (const p of e.detail.paths) {
668
+ console.log(e.detail.dirs.includes(p) ? "資料夾" : "檔案", p);
669
+ }
577
670
  });
578
671
  ```
579
672
 
@@ -584,14 +677,27 @@ window.addEventListener("shell-msg", (e) => {
584
677
  視窗外殼層不會向頁面隱式注入任何 DOM 或全域樣式。為消除「網頁感」,建議應用程式於前端自行補充以下基礎規則:
585
678
 
586
679
  ```css
587
- html, body { cursor: default; }
588
- body { user-select: none; }
589
- input, textarea, .log { user-select: text; cursor: auto; }
590
- button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
680
+ html,
681
+ body {
682
+ cursor: default;
683
+ }
684
+ body {
685
+ user-select: none;
686
+ }
687
+ input,
688
+ textarea,
689
+ .log {
690
+ user-select: text;
691
+ cursor: auto;
692
+ }
693
+ button:focus-visible {
694
+ outline: 2px solid var(--accent);
695
+ outline-offset: 2px;
696
+ }
591
697
  ```
592
698
 
593
699
  ```html
594
- <input type="text" spellcheck="false" autocomplete="off" autocapitalize="off">
700
+ <input type="text" spellcheck="false" autocomplete="off" autocapitalize="off" />
595
701
  ```
596
702
 
597
703
  尤其建議為輸入框設定 `spellcheck="false"`,否則瀏覽器拼字檢查極易在檔案路徑或專業數值下方繪製紅色波浪底線,破壞桌面應用程式的原生觀感。
@@ -602,7 +708,7 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
602
708
 
603
709
  ```ts
604
710
  import { createShell } from "bun-app-window";
605
- import { embeddedDll } from "bun-app-window/embedded"; // 將 DLL 位元組內嵌至封裝產物
711
+ import { embeddedDll } from "bun-app-window/embedded"; // 將 DLL 位元組內嵌至封裝產物
606
712
 
607
713
  const shell = await createShell({ url, embedded: embeddedDll });
608
714
  ```
@@ -641,18 +747,18 @@ bun build --compile --windows-hide-console src/index.ts --outfile dist/app.exe
641
747
 
642
748
  ### 內建快速鍵與滑鼠行為
643
749
 
644
- | 觸發方式 | 預設行為 |
645
- | --- | --- |
646
- | `Ctrl +` / `Ctrl -` / `Ctrl 0` | 頁面縮放:放大一階 / 縮小一階 / 重設為 100% |
647
- | `Ctrl + 滑鼠滾輪` | 頁面縮放:放大 / 縮小 |
648
- | `F11` | 切換全螢幕模式(隱藏視窗邊框並覆蓋系統工作列) |
649
- | `F5` / `Ctrl + R` | 重新載入目前頁面 |
650
- | `F12` | 開啟 WebView2 開發人員工具 |
651
- | `Alt + Space` / 標題列右鍵 | 彈出 Windows 系統視窗選單 |
750
+ | 觸發方式 | 預設行為 |
751
+ | ------------------------------ | ---------------------------------------------- |
752
+ | `Ctrl +` / `Ctrl -` / `Ctrl 0` | 頁面縮放:放大一階 / 縮小一階 / 重設為 100% |
753
+ | `Ctrl + 滑鼠滾輪` | 頁面縮放:放大 / 縮小 |
754
+ | `F11` | 切換全螢幕模式(隱藏視窗邊框並覆蓋系統工作列) |
755
+ | `F5` / `Ctrl + R` | 重新載入目前頁面 |
756
+ | `F12` | 開啟 WebView2 開發人員工具 |
757
+ | `Alt + Space` / 標題列右鍵 | 彈出 Windows 系統視窗選單 |
652
758
 
653
759
  ## 開發與驗證
654
760
 
655
- 本專案工具鏈基於 Bun 1.4.3 與 TypeScript 7.0.2 建構,`tsconfig.json` 開啟全量嚴格模式檢查。
761
+ 本專案工具鏈基於 Bun 1.4.4 與 TypeScript 7.0.2 建構,`tsconfig.json` 開啟全量嚴格模式檢查。
656
762
 
657
763
  **本專案不使用遠端 CI。** 該帳戶的 GitHub Actions 因 billing lock 無法分配 runner;而專案本身是單平台、單人開發、零外部服務相依的相依套件,遠端 CI 的邊際價值低於其維護成本,因此品質把關改為本機執行。建議入口:
658
764
 
@@ -661,9 +767,9 @@ bun run scripts/gate.ts # 對目前工作區執行三段把關
661
767
  bun run scripts/gate.ts --clean # 同上,但在 HEAD 的乾淨副本內執行
662
768
  ```
663
769
 
664
- `-Clean` 重現了託管 runner 的條件:將 `HEAD` clone 到 `%TEMP%`,依 lockfile 安裝相依套件,再於其中執行把關。如此一來,本機產物(`.build/`、`node_modules/`、`vendor/webview2/`)便無法掩蓋缺少的檔案。耗時約兩秒。未提交的變更會被提示並排除於乾淨副本之外——如需驗證工作區現況,請移除 `-Clean`。
770
+ `-Clean` 重現了託管 runner 的條件:將 `HEAD` clone 到 `%TEMP%`,依 lockfile 安裝相依套件,再於其中執行把關。如此一來,本機產物(`.build/`、`node_modules/`、`vendor/webview2/`)便無法掩蓋缺少的檔案。未提交的變更會被提示並排除於乾淨副本之外——如需驗證工作區現況,請移除 `-Clean`。
665
771
 
666
- 品質把關分三段:`bun check`、`bun test`,以及消費方驗收(`scripts/verify-pack.ts`)。第三段會把 tarball 打包到 `%TEMP%`,安裝進一個臨時專案,對該專案執行型別檢查,再執行 `scripts/consumer-probe.ts`——斷言涵蓋全部子路徑、DLL 是否真的從 `node_modules` 被 `dlopen`、以及 `libInfo.source` 是否為 `package`(條數由探針自報)。可離線執行,約 0.4 秒,且預設拒絕:任何未在允許清單登記的檔案被打進套件,把關就會失敗。只有在改動把關腳本本身時才用 `-SkipPack`。
772
+ 品質把關分三段:`bun check`、`bun test`,以及消費方驗收(`scripts/verify-pack.ts`)。第三段會把 tarball 打包到 `%TEMP%`,安裝進一個臨時專案,對該專案執行型別檢查,再執行 `scripts/consumer-probe.ts`——斷言涵蓋全部子路徑、DLL 是否真的從 `node_modules` 被 `dlopen`、以及 `libInfo.source` 是否為 `package`(條數由探針自報)。可離線執行,且預設拒絕:任何未在允許清單登記的檔案被打進套件,把關就會失敗。只有在改動把關腳本本身時才用 `-SkipPack`。
667
773
 
668
774
  底層指令仍可直接呼叫:
669
775
 
@@ -673,16 +779,16 @@ bun test # 執行自動化測試套件(位於 tests/)
673
779
  bun run demo # 啟動互動式自我檢測範例程式(需於桌面工作階段下執行)
674
780
  ```
675
781
 
676
- 需注意:`bun run demo` 與「編譯為單一可執行檔」的驗收(`AGENTS.md` 中的形態 B)皆需要互動式桌面,本就不具備 CI 可行性,屬於構造上的本機專屬項目。
782
+ 需注意:`bun run demo` 與單一 `.exe` 的驗收都需要互動式桌面,因此兩者都不具備 CI 可行性,屬於構造上的本機專屬項目。`.exe` 那條的完整判據是:編譯 → 把產物複製進一個空目錄 → 啟動 → 確認視窗起得來,且 `libInfo.source` 回報 `embedded`。
677
783
 
678
- 注意:每當修改 `src/shell/` 或 `package.json` 後,「作為外部專案相依套件安裝」這條鏈路(`AGENTS.md` 中的形態 A)已由消費方驗收階段自動涵蓋——它隨品質把關一起執行,不需要額外記得。只剩形態 B(編譯單一 `.exe`)仍需於桌面工作階段手動執行一次。
784
+ 每當修改 `src/shell/` 或 `package.json` 後,「作為外部專案相依套件安裝」這條鏈路已由消費方驗收階段自動涵蓋——它隨品質把關一起執行,不需要額外記得。只剩 `.exe` 建置仍需於桌面工作階段手動執行一次。
679
785
 
680
786
  自動化測試或截圖腳本若需透過 Win32 `EnumWindows` 過濾視窗控制代碼(HWND),請依據視窗模式比對對應的視窗類別名稱(Window Class):
681
787
 
682
- | 視窗模式 | 對應視窗類別名稱 |
683
- | --- | --- |
684
- | 標準有邊框模式(未傳 `FLAG_FRAMELESS`) | `webview` |
685
- | 無邊框模式(傳入 `FLAG_FRAMELESS`) | `webview_shell` |
788
+ | 視窗模式 | 對應視窗類別名稱 |
789
+ | --------------------------------------- | ---------------- |
790
+ | 標準有邊框模式(未傳 `FLAG_FRAMELESS`) | `webview` |
791
+ | 無邊框模式(傳入 `FLAG_FRAMELESS`) | `webview_shell` |
686
792
 
687
793
  ## 致謝
688
794