bun-app-window 0.7.0 → 0.9.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 原始碼,共 22 個檔案(0.48 MB,解包後 1.39 MB)。原生 C++ 層(`native/`)與上游 vendored 標頭檔(`vendor/`)**不隨套件散布**:它們只在重新編譯 `libshell.dll` 時需要,而重新編譯必須 clone 儲存庫。因此 `bun run build:native` 與 `build:exe` 是本儲存庫專用指令碼,安裝進 `node_modules` 後執行沒有意義。
51
+ 發佈的 tarball 只有預先編譯的 `dist/libshell.dll` 與 TypeScript 原始碼,共 22 個檔案(0.51 MB,解包後 1.50 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
 
@@ -83,45 +84,49 @@ shell.setTitle("Doing work…");
83
84
 
84
85
  ### 實例方法與屬性
85
86
 
86
- | 成員 | 說明 |
87
- | --- | --- |
88
- | `start()` | 建立並啟動視窗,回傳 `{ rc, error }`。呼叫後立即回傳,UI 訊息迴圈執行於獨立原生執行緒 |
89
- | `stop()` | 銷毀視窗並釋放底層資源,支援冪等重複呼叫 |
90
- | `show()` | 顯示視窗。常搭配 `FLAG_DEFER_SHOW` 使用,實現在頁面渲染就緒後再展現視窗 |
91
- | `isAlive()` | 檢測視窗是否存活(註:`state()` 無法區分「尚未啟動」與「已銷毀」狀態)。等視窗關閉請用 `pollEvents()` 的 `CLOSED`,別輪詢它 |
92
- | `pollEvents(max?)` | 批次拉取視窗生命週期事件(`STATE` / `CLOSED`),同步、消費式。詳見下文「視窗事件」 |
93
- | `state()` | 取得目前視窗狀態:`{ maximized, minimized, focused, dark, raw }` |
94
- | `lastError()` | 取得最近一次操作失敗的錯誤訊息,無錯誤時回傳空字串 |
95
- | `setTitle` / `minimize` / `toggleMaximize` / `close` | 基礎視窗控制操作,回傳狀態碼 `rc` |
96
- | `setMinSize(w, h)` | 設定視窗最小尺寸(單位:邏輯像素) |
97
- | `drag()` | 觸發原生視窗拖曳(於頁面 `pointerdown` 事件中呼叫,完整保留 Windows 貼齊版面配置特性) |
98
- | `beginResize(edge)` | 觸發原生邊框縮放(於頁面 `pointermove` 事件中呼叫,`edge` 取值見 `RESIZE_EDGE.*`) |
99
- | `post(json)` | 宿主向頁面單向投遞 JSON 訊息,非同步發送且不等待回傳結果 |
100
- | `setZoom(f)` / `getZoom()` | 設定或取得頁面縮放比例(有效範圍 `0.25`–`3.0`,越界值自動鉗制) |
101
- | `reload()` / `openDevTools()` / `toggleFullscreen()` | 頁面重新載入、開啟開發人員工具、切換全螢幕(分別對應 `Ctrl+R` / `F12` / `F11`) |
102
- | `setFrameless(frameless)` / `isFrameless()` | 執行時期切換視窗形態並讀取目前形態。只有**建立時**給了 `FLAG_FRAMELESS` 的視窗能切;帶邊框起點的視窗回傳 `rc=-1`,理由在 `lastError()`。詳見下文「視窗旗標」 |
103
- | `setBrowserUi(mask)` | 停用指定的瀏覽器預設行為,傳入 `BROWSER_UI_*` 旗標的按位元 OR 組合 |
104
- | `showContextMenu(x, y, items)` | 於指定螢幕座標彈出 Win32 原生右鍵快顯功能表(詳見下文用法) |
105
- | `pickFolder(title?)` | 呼叫原生資料夾選擇對話方塊(同步阻塞目前呼叫執行緒;**經 IPC 暴露時務必搭配 `timeoutMs: 0`**,見下文) |
106
- | `libInfo` | 原生動態連結程式庫解析中繼資料(包含實際路徑、載入來源、SHA-256 雜湊值) |
107
- | `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
+ | `getRect()` | 讀取目前視窗矩形的結構化讀數 `{ x, y, width, height, maximized, dpi }`(物理像素);讀不到或資料不可用時回傳 `null`。視窗未啟動、尺寸/DPI 為 0、最小化哨兵座標三種情況都回傳 `null` |
96
+ | `getTitle()` | 讀回視窗標題的**真實值**(原生 `GetWindowTextW`,不是任何快取)。視窗不存在、或標題超過 511 個字元時回傳 `null`——**絕不回傳被截斷的字串**;空標題回傳 `""`。`setTitle()` 的 `rc === 0` 只代表訊息投遞成功,要判斷「效果發生」必須讀這裡 |
97
+ | `getMinSize()` | 讀回**生效中**的最小尺寸 `{ width, height }`(邏輯像素);視窗不存在時回傳 `null`。讀的不是「最後一次 set 的參數」——`setMinSize(0, 0)` 對非正值是忽略的,所以這裡給出的是 `WM_GETMINMAXINFO` 真正會用的那一個 |
98
+ | `getBrowserUi()` | 已請求關閉的瀏覽器行為位元遮罩。**累積語意**:先 `BROWSER_UI_MENU` 再 `BROWSER_UI_KEYS`,讀回來是兩者之和——因為 `setBrowserUi()` 只有「關」的方向。視窗未啟動時回傳 `0`(準確答案,不是「讀不到」) |
99
+ | `lastError()` | 取得最近一次操作失敗的錯誤訊息,無錯誤時回傳空字串 |
100
+ | `setTitle` / `minimize` / `toggleMaximize` / `close` | 基礎視窗控制操作,回傳狀態碼 `rc` |
101
+ | `setMinSize(w, h)` | 設定視窗最小尺寸(單位:邏輯像素) |
102
+ | `drag()` | 觸發原生視窗拖曳(於頁面 `pointerdown` 事件中呼叫,完整保留 Windows 貼齊版面配置特性) |
103
+ | `beginResize(edge)` | 觸發原生邊框縮放(於頁面 `pointermove` 事件中呼叫,`edge` 取值見 `RESIZE_EDGE.*`) |
104
+ | `post(json)` | 宿主向頁面單向投遞 JSON 訊息,非同步發送且不等待回傳結果 |
105
+ | `setZoom(f)` / `getZoom()` | 設定或取得頁面縮放比例(有效範圍 `0.25`–`3.0`,越界值自動鉗制) |
106
+ | `reload()` / `openDevTools()` / `toggleFullscreen()` | 頁面重新載入、開啟開發人員工具、切換全螢幕(分別對應 `Ctrl+R` / `F12` / `F11`) |
107
+ | `setFrameless(frameless)` / `isFrameless()` | 執行時期切換視窗形態並讀取目前形態。只有**建立時**給了 `FLAG_FRAMELESS` 的視窗能切;帶邊框起點的視窗回傳 `rc=-1`,理由在 `lastError()`。詳見下文「視窗旗標」 |
108
+ | `setBrowserUi(mask)` | 停用指定的瀏覽器預設行為,傳入 `BROWSER_UI_*` 旗標的按位元 OR 組合 |
109
+ | `showContextMenu(x, y, items)` | 於指定螢幕座標彈出 Win32 原生右鍵快顯功能表(詳見下文用法) |
110
+ | `pickFolder(title?)` | 呼叫原生資料夾選擇對話方塊(同步阻塞目前呼叫執行緒;**經 IPC 暴露時務必搭配 `timeoutMs: 0`**,見下文) |
111
+ | `libInfo` | 原生動態連結程式庫解析中繼資料(包含實際路徑、載入來源、SHA-256 雜湊值) |
112
+ | `symbols` | 底層直通介面:直接存取透過 `dlopen` 匯出的底層 C 符號 |
108
113
 
109
114
  ### 視窗旗標(Flags)
110
115
 
111
116
  透過按位元 OR(`|`)組合傳入 `opts.flags`:
112
117
 
113
- | 旗標 | 作用說明 |
114
- | --- | --- |
115
- | `FLAG_FRAMELESS` | 無邊框視窗模式,移除系統預設標題列,交由前端頁面自行繪製。以這種方式建立的視窗可在執行時期用 `setFrameless(false)` 切回帶邊框 |
116
- | `FLAG_RESIZABLE` | 允許使用者調整視窗大小 |
117
- | `FLAG_CENTER` | 啟動時於目前顯示器工作區置中顯示(有邊框與無邊框模式均生效) |
118
- | `FLAG_TOPMOST` | 視窗保持最上層顯示 |
119
- | `FLAG_DEFER_SHOW` | 建立視窗後保持隱藏狀態,等待明確呼叫 `show()` |
120
- | `FLAG_ACCEPT_DROP` | 允許視窗接收外部檔案或資料夾拖放事件 |
121
- | `FLAG_START_MAXIMIZED` | 視窗首次顯示即處於最大化狀態,避免從一般尺寸跳變至最大化的中間閃爍畫格 |
122
- | `FLAG_ALLOW_EXTERNAL_NAV` | 放棄外部導覽攔截——用於本身就是瀏覽器的應用程式 |
123
- | `FLAG_TITLEBAR_RESERVE` | 無邊框模式下把網頁內容整體下移一個標題列高度,讓頂端那一條歸原生視窗。**已被 `FLAG_NATIVE_CAPTIONS` 取代**:caption 命中區固定在客戶區 `y ∈ [0, 32)`,這條路會把頁面自繪的標題列放到與點擊真正落點相差 32 px 的位置 |
124
- | `FLAG_NATIVE_CAPTIONS` | 無邊框模式下,由殼層自己的 layered 子視窗繪製三個 caption 按鈕並在那裡應答 `HTMAXBUTTON`。這是 Snap Layouts 唯一能觸發的途徑:WebView2 的渲染子視窗在另一個處理序裡,恆回 `HTCLIENT`。必須與 `mountTitleBar({ captions: "native" })` 搭配,頁面才會留出 `46 × 3` 而不是自己畫一套 |
118
+ | 旗標 | 作用說明 |
119
+ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
120
+ | `FLAG_FRAMELESS` | 無邊框視窗模式,移除系統預設標題列,交由前端頁面自行繪製。以這種方式建立的視窗可在執行時期用 `setFrameless(false)` 切回帶邊框 |
121
+ | `FLAG_RESIZABLE` | 允許使用者調整視窗大小 |
122
+ | `FLAG_CENTER` | 啟動時於目前顯示器工作區置中顯示(有邊框與無邊框模式均生效) |
123
+ | `FLAG_TOPMOST` | 視窗保持最上層顯示 |
124
+ | `FLAG_DEFER_SHOW` | 建立視窗後保持隱藏狀態,等待明確呼叫 `show()` |
125
+ | `FLAG_ACCEPT_DROP` | 允許視窗接收外部檔案或資料夾拖放事件 |
126
+ | `FLAG_START_MAXIMIZED` | 視窗首次顯示即處於最大化狀態,避免從一般尺寸跳變至最大化的中間閃爍畫格 |
127
+ | `FLAG_ALLOW_EXTERNAL_NAV` | 放棄外部導覽攔截——用於本身就是瀏覽器的應用程式 |
128
+ | `FLAG_TITLEBAR_RESERVE` | 無邊框模式下把網頁內容整體下移一個標題列高度,讓頂端那一條歸原生視窗。**已被 `FLAG_NATIVE_CAPTIONS` 取代**:caption 命中區固定在客戶區 `y ∈ [0, 32)`,這條路會把頁面自繪的標題列放到與點擊真正落點相差 32 px 的位置 |
129
+ | `FLAG_NATIVE_CAPTIONS` | 無邊框模式下,由殼層自己的 layered 子視窗繪製三個 caption 按鈕並在那裡應答 `HTMAXBUTTON`。這是 Snap Layouts 唯一能觸發的途徑:WebView2 的渲染子視窗在另一個處理序裡,恆回 `HTCLIENT`。必須與 `mountTitleBar({ captions: "native" })` 搭配,頁面才會留出 `46 × 3` 而不是自己畫一套 |
125
130
 
126
131
  ### 外部導覽預設被攔截
127
132
 
@@ -165,18 +170,18 @@ import htmlText from "./index.html" with { type: "text" };
165
170
  const html = htmlText as unknown as string;
166
171
 
167
172
  const app = await createApp({
168
- html,
169
- title: "我的應用程式",
170
- flags: FLAG_FRAMELESS, // 想要自繪標題列就明確加上
171
- browserUi: BROWSER_UI_ALL,
172
- service: {
173
- greet: (name: string) => `hi ${name}`,
174
- pickFolder: () => app.shell.pickFolder(),
175
- },
173
+ html,
174
+ title: "我的應用程式",
175
+ flags: FLAG_FRAMELESS, // 想要自繪標題列就明確加上
176
+ browserUi: BROWSER_UI_ALL,
177
+ service: {
178
+ greet: (name: string) => `hi ${name}`,
179
+ pickFolder: () => app.shell.pickFolder(),
180
+ },
176
181
  });
177
182
 
178
- await app.wait(); // 視窗關閉時 resolve
179
- await app.stop(); // 釋放事件泵與服務;冪等,已關閉時立即回傳
183
+ await app.wait(); // 視窗關閉時 resolve
184
+ await app.stop(); // 釋放事件泵與服務;冪等,已關閉時立即回傳
180
185
  ```
181
186
 
182
187
  傳 `page` 就能把打包好的頁面指令碼交給骨架,不必自己寫那條路由。`createApp` 會應答
@@ -203,7 +208,7 @@ const app = await createApp({ html, page: code, service });
203
208
  ```ts
204
209
  // page.ts —— index.html 的 bundle 內
205
210
  import { connect } from "bun-app-window/ipc";
206
- const api = await connect(); // token 從頁面 URL 的 ?t= 取得
211
+ const api = await connect(); // token 從頁面 URL 的 ?t= 取得
207
212
  await api.greet("Ada");
208
213
  ```
209
214
 
@@ -216,6 +221,17 @@ await api.greet("Ada");
216
221
  - `stop()` 有序且冪等:`shell.stop()` → 等 `EVENT_CLOSED`(帶 5 秒上限)→ `server.stop()`。
217
222
  - 起一個視窗事件泵並且只起一個:`app.events`。它同時承擔「等視窗關閉」,
218
223
  所以是唯一的事件消費者;`attachPlacementStore` 要用它時傳 `{ events: app.events }`。
224
+ - 把頁面端的例外回傳給宿主。未捕捉例外與未處理的 Promise 拒絕由注入的監聽腳本捕捉,
225
+ 經 HTTP 上報——不走 WebSocket:頁面腳本在求值階段崩潰時通道尚未建立,而那正是最需要
226
+ 觀測的情形。傳 `onPageError` 接收;不傳時輸出到宿主的 stderr,而不是丟棄。
227
+ - 啟動前校驗 `html` 與 `page` 的一致性。只給其中一個屬於設定錯誤,唯一症狀是視窗正常繪製
228
+ 但任何互動都無回應,因此直接拋錯。引用存在但缺少 `type="module"` 時只警告:`<script>`
229
+ 的合法寫法不只一種,字串比對的置信度不足以阻斷啟動。
230
+ - 用 `assets` 服務額外的靜態檔案。編譯型框架通常產出不止一個產物(典型是腳本 + 樣式表),
231
+ `assets` 是一張「路徑 → 文字」的映射,`content-type` 由副檔名推斷。此前每個消費者都要
232
+ 手寫一段 `routes` 回呼來服務樣式表,而最常見的錯法(MIME 型別寫錯)會讓瀏覽器靜默丟棄
233
+ 樣式。未辨識的副檔名回退到 `application/octet-stream` 而不是猜測。呼叫方的 `routes`
234
+ 仍然優先,未登記的路徑回傳 404。
219
235
  - 啟動失敗不留半啟動狀態:`openShell` 拋錯時,已綁定的埠會被釋放。
220
236
 
221
237
  注意事項:
@@ -253,28 +269,45 @@ export type Events = { windowState: { maximized: boolean } };
253
269
  import { createIpcServer } from "bun-app-window/ipc";
254
270
 
255
271
  const ipc = createIpcServer({
256
- service: { greet: (name: string) => `Hello ${name}` },
272
+ service: { greet: (name: string) => `Hello ${name}` },
257
273
  });
258
274
 
259
275
  serveOnSafePort({
260
- fetch(req) { /* 處理靜態頁面與驗證 */ },
261
- websocket: {
262
- open: (ws) => ipc.attach(ws),
263
- message: (ws, raw) => ipc.handle(ws, raw),
264
- close: (ws) => ipc.detach(ws),
265
- },
276
+ fetch(req) {
277
+ /* 處理靜態頁面與驗證 */
278
+ },
279
+ websocket: {
280
+ open: (ws) => ipc.attach(ws),
281
+ message: (ws, raw) => ipc.handle(ws, raw),
282
+ close: (ws) => ipc.detach(ws),
283
+ },
266
284
  });
267
285
 
268
- ipc.emit("windowState", { maximized: false }); // 向頁面主動推播事件
286
+ ipc.emit("windowState", { maximized: false }); // 向頁面主動推播事件
269
287
  ```
270
288
 
289
+ #### 遲連線的狀態投遞
290
+
291
+ 頁面在視窗建立**之後**才載入。宿主從 `createApp` 回傳、到頁面的 WebSocket 建立之間發出的
292
+ 任何事件,都投遞給零個連線,也不會被重播——只從事件讀取初始狀態的頁面會一直是空值,
293
+ 而兩端都不會報錯。
294
+
295
+ 把「狀態類」事件標為黏性,服務端就會在每次連線建立時重播最後一份酬載:
296
+
297
+ ```ts
298
+ ipc.emit("windowState", { maximized: false }, { sticky: true }); // attach() 時重播
299
+ ```
300
+
301
+ 黏性是**明確開啟**的,且每個事件名稱只保留一份酬載,不是歷史佇列。語意上是「通知」而非
302
+ 「狀態」的事件不應開啟:遲到的連線會額外收到一條讀起來像新訊息的舊訊息。
303
+
271
304
  ```ts
272
305
  // 頁面端
273
306
  import { connect } from "bun-app-window/ipc";
274
307
  import type { Api, Events } from "./api";
275
308
 
276
309
  const api = await connect<Api, Events>();
277
- await api.greet("Ada"); // 回傳型別自動推導為 string
310
+ await api.greet("Ada"); // 回傳型別自動推導為 string
278
311
  api.on("windowState", (s) => bar.setMaximized(s.maximized));
279
312
  ```
280
313
 
@@ -307,17 +340,21 @@ const api = await connect<Api, Events>({ timeoutMs: 0 });
307
340
  用戶端是一個 `Proxy`:有幾個名稱會解析到用戶端自己身上,永遠到不了遠端方法。服務表裡若有
308
341
  同名方法,**那個服務端方法就永遠呼叫不到**——頁面呼叫的是用戶端自己的成員,兩端都不會報錯。
309
342
 
310
- | 名稱 | 頁面實際拿到的東西 |
311
- | --- | --- |
312
- | `on` / `off` | 事件訂閱輔助函式 |
313
- | `close()` | 關閉 WebSocket 通道——之後所有呼叫都會被 reject |
314
- | `connected` | `boolean`,通道目前是否可用 |
315
- | `pending` | `number`,尚未收到回覆的呼叫數 |
316
- | `then` | `undefined`,否則 `await client` 會把這個永不兌現的 thenable 等下去 |
343
+ | 名稱 | 頁面實際拿到的東西 |
344
+ | ------------ | ------------------------------------------------------------------- |
345
+ | `on` / `off` | 事件訂閱輔助函式 |
346
+ | `close()` | 關閉 WebSocket 通道——之後所有呼叫都會被 reject |
347
+ | `connected` | `boolean`,通道目前是否可用 |
348
+ | `pending` | `number`,尚未收到回覆的呼叫數 |
349
+ | `then` | `undefined`,否則 `await client` 會把這個永不兌現的 thenable 等下去 |
317
350
 
318
351
  Symbol 鍵同樣保留。給服務方法換個名稱(`winClose` 而不是 `close`),在頁面端映射回去 ——
319
352
  見下方的標題列一節。
320
353
 
354
+ 上述失效在執行期完全不可觀測,因此 `createIpcServer` 在**建立時就會拋錯**:服務表裡出現這些
355
+ 名稱即終止,錯誤訊息指出衝突的鍵並給出替代命名。只有當服務表由外部輸入產生、且已用其他手段
356
+ 驗證過時,才應傳 `{ rejectReservedNames: false }` 關閉本檢查。
357
+
321
358
  ### `bun-app-window/titlebar` — Windows 11 風格標題列
322
359
 
323
360
  用於在無邊框模式下快速掛載符合 Windows 11 視覺規範的自訂標題列。IPC 服務是**扁平的**——
@@ -327,12 +364,12 @@ Symbol 鍵同樣保留。給服務方法換個名稱(`winClose` 而不是 `clo
327
364
  ```ts
328
365
  // 宿主端 —— 扁平服務;`close` 是 IPC 用戶端的保留名稱
329
366
  createIpcServer({
330
- service: {
331
- minimize: () => shell.minimize(),
332
- toggleMaximize: () => shell.toggleMaximize(),
333
- winClose: () => shell.close(),
334
- drag: () => shell.drag(),
335
- },
367
+ service: {
368
+ minimize: () => shell.minimize(),
369
+ toggleMaximize: () => shell.toggleMaximize(),
370
+ winClose: () => shell.close(),
371
+ drag: () => shell.drag(),
372
+ },
336
373
  });
337
374
  ```
338
375
 
@@ -341,14 +378,14 @@ createIpcServer({
341
378
  import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
342
379
 
343
380
  const bar = mountTitleBar({
344
- title: "我的應用程式",
345
- icon: "/icon.svg",
346
- bridge: bridgeFromWindowService({
347
- minimize: () => api.minimize(),
348
- toggleMaximize: () => api.toggleMaximize(),
349
- close: () => api.winClose(), // 橋接時再映射回 close
350
- drag: () => api.drag(),
351
- }),
381
+ title: "我的應用程式",
382
+ icon: "/icon.svg",
383
+ bridge: bridgeFromWindowService({
384
+ minimize: () => api.minimize(),
385
+ toggleMaximize: () => api.toggleMaximize(),
386
+ close: () => api.winClose(), // 橋接時再映射回 close
387
+ drag: () => api.drag(),
388
+ }),
352
389
  });
353
390
 
354
391
  api.on("windowState", (s) => bar.setMaximized(s.maximized));
@@ -376,7 +413,7 @@ if (saved) restorePlacement(shell, saved);
376
413
 
377
414
  shell.show();
378
415
 
379
- const store = attachPlacementStore(shell, FILE); // 訂閱視窗事件,拖曳停下後寫入一次
416
+ const store = attachPlacementStore(shell, FILE); // 訂閱視窗事件,拖曳停下後寫入一次
380
417
  process.on("beforeExit", () => store.flush());
381
418
  ```
382
419
 
@@ -410,8 +447,10 @@ const store = attachPlacementStore(app.shell, FILE, { events: app.events });
410
447
  import { EVENT_CLOSED, EVENT_STATE } from "bun-app-window/events";
411
448
 
412
449
  for (const e of shell.pollEvents()) {
413
- if (e.type === EVENT_CLOSED) break; // 視窗已銷毀,原生單例已釋放
414
- if (e.type === EVENT_STATE && e.maximized) { /* ... */ }
450
+ if (e.type === EVENT_CLOSED) break; // 視窗已銷毀,原生單例已釋放
451
+ if (e.type === EVENT_STATE && e.maximized) {
452
+ /* ... */
453
+ }
415
454
  }
416
455
  ```
417
456
 
@@ -422,7 +461,9 @@ for (const e of shell.pollEvents()) {
422
461
  import { EVENT_CLOSED, attachEventPump } from "bun-app-window/events";
423
462
 
424
463
  const pump = attachEventPump(shell, { intervalMs: 16 });
425
- pump.on((e) => { if (e.type === EVENT_CLOSED) pump.stop(); });
464
+ pump.on((e) => {
465
+ if (e.type === EVENT_CLOSED) pump.stop();
466
+ });
426
467
  pump.onGap((missing) => console.warn(`漏了 ${missing} 條事件`));
427
468
  ```
428
469
 
@@ -442,8 +483,8 @@ pump.onGap((missing) => console.warn(`漏了 ${missing} 條事件`));
442
483
  import { debugOf } from "bun-app-window/debug";
443
484
 
444
485
  const d = debugOf(shell);
445
- d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
446
- d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
486
+ d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
487
+ d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
447
488
  d.hittest(x, y);
448
489
  ```
449
490
 
@@ -484,6 +525,56 @@ await writePageBundle({ entry: "src/ui/page.ts", out: "src/ui/page.bundle.txt" }
484
525
  `bytes` 是 UTF-8 位元組數,不是 `string.length`;原始碼裡只要有非 ASCII 文字兩者就不同,
485
526
  而一個「看起來對」的體積讀數比沒有讀數更糟。
486
527
 
528
+ #### 編譯型框架:`plugins`、`define`、`loader`
529
+
530
+ 這三個欄位原樣交給 `Bun.build`,與任何具體框架無關,本套件不自帶任何外掛。
531
+
532
+ ```ts
533
+ const built = await bundlePage({
534
+ entry: "src/main.ts",
535
+ plugins: [myFrameworkPlugin()], // .vue / .svelte / .tsx —— 外掛能編譯什麼就傳什麼
536
+ define: { __VUE_OPTIONS_API__: "false" }, // 編譯期常數
537
+ loader: { ".thing": "ts" }, // 自訂副檔名
538
+ });
539
+ ```
540
+
541
+ #### `alias` 由本套件實作,因為 Bun 沒有這個選項
542
+
543
+ Bun 1.4.4 的 `BuildConfig` 沒有 `alias` 屬性,而且傳進去不會報錯——它被靜默忽略。
544
+ 四種寫法都試過,全部解析失敗。因此本套件用自帶的 `onResolve` 外掛支撐 `alias`:
545
+
546
+ ```ts
547
+ await bundlePage({ entry, alias: { "@": "./src", lib: "./src/lib" } });
548
+ ```
549
+
550
+ | 模組指定符 | 是否重寫 |
551
+ | ------------------------------- | -------- |
552
+ | 與某個鍵相等,或以 `鍵/` 為前綴 | 是 |
553
+ | 以 `.` 或 `/` 開頭 | 否 |
554
+ | 帶 `scheme:` 前綴 | 否 |
555
+
556
+ **預設是空的。** `$lib` 是 SvelteKit 的約定,`@/` 是某些專案自己的 `tsconfig` 別名,
557
+ 兩者都不屬於本套件。別名與呼叫方的外掛是合併關係而非取代,且只在傳入 `alias` 時才註冊該外掛。
558
+
559
+ #### 多產物
560
+
561
+ 編譯型框架經常產出不止一個產物——指令碼加樣式表是常見形態。`outputs` 列出全部,每項帶
562
+ `path` / `kind` / `code` / `bytes`,而 `code` 與 `bytes` 特指 `entry-point` 那一個
563
+ (不是 `outputs[0]`,輸出順序不構成契約)。
564
+
565
+ `writePageBundle` 寫出每一個產物:入口寫到 `out`,其餘寫到同目錄並替換副檔名。
566
+ 所以 `page.bundle.txt` 會得到 `page.bundle.css`,**不是** `main.css`——入口的檔名不該
567
+ 滲進一個 HTML 與 `assets` 會引用的路徑。它回傳 `written: Array<{ path, bytes }>`,
568
+ 便於清理時刪掉自己產生的每一個檔案。
569
+
570
+ ```text
571
+ $ bun run bundle:page
572
+ 109,888 B dist/page.bundle.txt ← 入口產物
573
+ 133 B dist/page.bundle.css ← 附帶產物
574
+ ```
575
+
576
+ 其餘檔案用 `createApp({ assets })` 服務——見上文的 `app` 小節。
577
+
487
578
  失敗時拋錯並帶上 Bun 的完整診斷。兩種形態都覆蓋了:編譯期問題以
488
579
  `{ success: false, logs }` 回傳,而入口本身不可達時**拋 `AggregateError`**,
489
580
  它的 `message` 只有一句 `Bundle failed`,細節在 `.errors` 上。
@@ -514,13 +605,13 @@ const shell = await openShell({ url, browserUi: BROWSER_UI_ALL });
514
605
 
515
606
  亦可依需求組合以下旗標:
516
607
 
517
- | 旗標 | 停用的瀏覽器行為 |
518
- | --- | --- |
519
- | `BROWSER_UI_MENU` | 預設網頁右鍵選單(Edge 快顯功能表) |
520
- | `BROWSER_UI_KEYS` | 瀏覽器內建快速鍵(如 `Ctrl+P` / `Ctrl+F` / `Ctrl+S`) |
521
- | `BROWSER_UI_STATUSBAR` | 左下角連結懸停狀態列 |
522
- | `BROWSER_UI_SWIPE` | 觸控板雙指橫滑上一頁 / 下一頁手勢 |
523
- | `BROWSER_UI_AUTOFILL` | 表單自動填入提示 |
608
+ | 旗標 | 停用的瀏覽器行為 |
609
+ | ---------------------- | ----------------------------------------------------- |
610
+ | `BROWSER_UI_MENU` | 預設網頁右鍵選單(Edge 快顯功能表) |
611
+ | `BROWSER_UI_KEYS` | 瀏覽器內建快速鍵(如 `Ctrl+P` / `Ctrl+F` / `Ctrl+S`) |
612
+ | `BROWSER_UI_STATUSBAR` | 左下角連結懸停狀態列 |
613
+ | `BROWSER_UI_SWIPE` | 觸控板雙指橫滑上一頁 / 下一頁手勢 |
614
+ | `BROWSER_UI_AUTOFILL` | 表單自動填入提示 |
524
615
 
525
616
  註:`F11`、`F12` 及 `Ctrl±` 縮放由視窗外殼層直接接管,不受上述旗標影響。
526
617
 
@@ -531,26 +622,26 @@ const shell = await openShell({ url, browserUi: BROWSER_UI_ALL });
531
622
  ```ts
532
623
  // 宿主端:將選單能力掛載到扁平服務上
533
624
  createIpcServer({
534
- service: {
535
- showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
536
- },
625
+ service: {
626
+ showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
627
+ },
537
628
  });
538
629
  ```
539
630
 
540
631
  ```ts
541
632
  // 頁面端觸發選單
542
633
  document.addEventListener("contextmenu", async (e) => {
543
- e.preventDefault();
544
- await api.showContextMenu(
545
- Math.round(e.screenX),
546
- Math.round(e.screenY),
547
- "open|開啟\nrefresh|重新整理\n-\nquit|結束"
548
- );
634
+ e.preventDefault();
635
+ await api.showContextMenu(
636
+ Math.round(e.screenX),
637
+ Math.round(e.screenY),
638
+ "open|開啟\nrefresh|重新整理\n-\nquit|結束",
639
+ );
549
640
  });
550
641
 
551
642
  // 頁面端監聽選單點擊結果
552
643
  window.addEventListener("shell-msg", (e) => {
553
- if (e.detail.t === "menu") console.log("選取選單項目 ID:", e.detail.id);
644
+ if (e.detail.t === "menu") console.log("選取選單項目 ID:", e.detail.id);
554
645
  });
555
646
  ```
556
647
 
@@ -568,7 +659,7 @@ const shell = await openShell({ url, flags: FLAG_ACCEPT_DROP });
568
659
  ```js
569
660
  // 頁面端接收路徑清單
570
661
  window.addEventListener("shell-msg", (e) => {
571
- if (e.detail.t === "drop") console.log("拖入檔案路徑:", e.detail.paths);
662
+ if (e.detail.t === "drop") console.log("拖入檔案路徑:", e.detail.paths);
572
663
  });
573
664
  ```
574
665
 
@@ -576,10 +667,10 @@ window.addEventListener("shell-msg", (e) => {
576
667
 
577
668
  ```js
578
669
  window.addEventListener("shell-msg", (e) => {
579
- if (e.detail.t !== "drop") return;
580
- for (const p of e.detail.paths) {
581
- console.log(e.detail.dirs.includes(p) ? "資料夾" : "檔案", p);
582
- }
670
+ if (e.detail.t !== "drop") return;
671
+ for (const p of e.detail.paths) {
672
+ console.log(e.detail.dirs.includes(p) ? "資料夾" : "檔案", p);
673
+ }
583
674
  });
584
675
  ```
585
676
 
@@ -590,14 +681,27 @@ window.addEventListener("shell-msg", (e) => {
590
681
  視窗外殼層不會向頁面隱式注入任何 DOM 或全域樣式。為消除「網頁感」,建議應用程式於前端自行補充以下基礎規則:
591
682
 
592
683
  ```css
593
- html, body { cursor: default; }
594
- body { user-select: none; }
595
- input, textarea, .log { user-select: text; cursor: auto; }
596
- button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
684
+ html,
685
+ body {
686
+ cursor: default;
687
+ }
688
+ body {
689
+ user-select: none;
690
+ }
691
+ input,
692
+ textarea,
693
+ .log {
694
+ user-select: text;
695
+ cursor: auto;
696
+ }
697
+ button:focus-visible {
698
+ outline: 2px solid var(--accent);
699
+ outline-offset: 2px;
700
+ }
597
701
  ```
598
702
 
599
703
  ```html
600
- <input type="text" spellcheck="false" autocomplete="off" autocapitalize="off">
704
+ <input type="text" spellcheck="false" autocomplete="off" autocapitalize="off" />
601
705
  ```
602
706
 
603
707
  尤其建議為輸入框設定 `spellcheck="false"`,否則瀏覽器拼字檢查極易在檔案路徑或專業數值下方繪製紅色波浪底線,破壞桌面應用程式的原生觀感。
@@ -608,7 +712,7 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
608
712
 
609
713
  ```ts
610
714
  import { createShell } from "bun-app-window";
611
- import { embeddedDll } from "bun-app-window/embedded"; // 將 DLL 位元組內嵌至封裝產物
715
+ import { embeddedDll } from "bun-app-window/embedded"; // 將 DLL 位元組內嵌至封裝產物
612
716
 
613
717
  const shell = await createShell({ url, embedded: embeddedDll });
614
718
  ```
@@ -647,18 +751,18 @@ bun build --compile --windows-hide-console src/index.ts --outfile dist/app.exe
647
751
 
648
752
  ### 內建快速鍵與滑鼠行為
649
753
 
650
- | 觸發方式 | 預設行為 |
651
- | --- | --- |
652
- | `Ctrl +` / `Ctrl -` / `Ctrl 0` | 頁面縮放:放大一階 / 縮小一階 / 重設為 100% |
653
- | `Ctrl + 滑鼠滾輪` | 頁面縮放:放大 / 縮小 |
654
- | `F11` | 切換全螢幕模式(隱藏視窗邊框並覆蓋系統工作列) |
655
- | `F5` / `Ctrl + R` | 重新載入目前頁面 |
656
- | `F12` | 開啟 WebView2 開發人員工具 |
657
- | `Alt + Space` / 標題列右鍵 | 彈出 Windows 系統視窗選單 |
754
+ | 觸發方式 | 預設行為 |
755
+ | ------------------------------ | ---------------------------------------------- |
756
+ | `Ctrl +` / `Ctrl -` / `Ctrl 0` | 頁面縮放:放大一階 / 縮小一階 / 重設為 100% |
757
+ | `Ctrl + 滑鼠滾輪` | 頁面縮放:放大 / 縮小 |
758
+ | `F11` | 切換全螢幕模式(隱藏視窗邊框並覆蓋系統工作列) |
759
+ | `F5` / `Ctrl + R` | 重新載入目前頁面 |
760
+ | `F12` | 開啟 WebView2 開發人員工具 |
761
+ | `Alt + Space` / 標題列右鍵 | 彈出 Windows 系統視窗選單 |
658
762
 
659
763
  ## 開發與驗證
660
764
 
661
- 本專案工具鏈基於 Bun 1.4.3 與 TypeScript 7.0.2 建構,`tsconfig.json` 開啟全量嚴格模式檢查。
765
+ 本專案工具鏈基於 Bun 1.4.4 與 TypeScript 7.0.2 建構,`tsconfig.json` 開啟全量嚴格模式檢查。
662
766
 
663
767
  **本專案不使用遠端 CI。** 該帳戶的 GitHub Actions 因 billing lock 無法分配 runner;而專案本身是單平台、單人開發、零外部服務相依的相依套件,遠端 CI 的邊際價值低於其維護成本,因此品質把關改為本機執行。建議入口:
664
768
 
@@ -685,10 +789,10 @@ bun run demo # 啟動互動式自我檢測範例程式(需於桌面工
685
789
 
686
790
  自動化測試或截圖腳本若需透過 Win32 `EnumWindows` 過濾視窗控制代碼(HWND),請依據視窗模式比對對應的視窗類別名稱(Window Class):
687
791
 
688
- | 視窗模式 | 對應視窗類別名稱 |
689
- | --- | --- |
690
- | 標準有邊框模式(未傳 `FLAG_FRAMELESS`) | `webview` |
691
- | 無邊框模式(傳入 `FLAG_FRAMELESS`) | `webview_shell` |
792
+ | 視窗模式 | 對應視窗類別名稱 |
793
+ | --------------------------------------- | ---------------- |
794
+ | 標準有邊框模式(未傳 `FLAG_FRAMELESS`) | `webview` |
795
+ | 無邊框模式(傳入 `FLAG_FRAMELESS`) | `webview_shell` |
692
796
 
693
797
  ## 致謝
694
798
 
package/dist/libshell.dll CHANGED
Binary file