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/CHANGELOG.md +336 -73
- package/README.md +251 -142
- package/README.zh-CN.md +242 -142
- package/README.zh-TW.md +245 -141
- package/dist/libshell.dll +0 -0
- package/package.json +9 -5
- package/src/shell/app.ts +255 -2
- package/src/shell/assets.d.ts +4 -4
- package/src/shell/embedded.ts +9 -10
- package/src/shell/index.ts +380 -318
- package/src/shell/ipc.ts +91 -33
- package/src/shell/native.ts +69 -64
- package/src/shell/pack.ts +247 -28
- package/src/shell/port.ts +37 -38
- package/src/shell/resolve.ts +121 -140
- package/src/shell/state.ts +28 -74
- package/src/shell/types.ts +206 -81
- package/src/ui/titlebar.ts +1 -2
package/src/shell/index.ts
CHANGED
|
@@ -26,15 +26,19 @@ import type { NativeSymbols } from "./native";
|
|
|
26
26
|
import { resolveNativeLib } from "./resolve";
|
|
27
27
|
import { EVENT_QUEUE_CAPACITY, EVENT_RECORD_BYTES, decodeEvents, type WindowEvent } from "./events";
|
|
28
28
|
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
STATE_FOCUSED,
|
|
30
|
+
STATE_MAXIMIZED,
|
|
31
|
+
STATE_MINIMIZED,
|
|
32
|
+
parseBounds,
|
|
33
|
+
parseSize,
|
|
34
|
+
resizeIndex,
|
|
35
|
+
type NativeLibInfo,
|
|
36
|
+
type ResizeEdge,
|
|
37
|
+
type ShellOptions,
|
|
38
|
+
type StartResult,
|
|
39
|
+
type WindowRect,
|
|
40
|
+
type WindowSize,
|
|
41
|
+
type WindowState,
|
|
38
42
|
} from "./types";
|
|
39
43
|
|
|
40
44
|
export * from "./types";
|
|
@@ -44,11 +48,7 @@ export { serveOnSafePort, isRestrictedPort } from "./port";
|
|
|
44
48
|
export type { ServeOptions } from "./port";
|
|
45
49
|
export { cstr, readCStr } from "./native";
|
|
46
50
|
export type { NativeSymbols, NativeLib } from "./native";
|
|
47
|
-
export type {
|
|
48
|
-
ClosedEvent,
|
|
49
|
-
StateEvent,
|
|
50
|
-
WindowEvent,
|
|
51
|
-
} from "./events";
|
|
51
|
+
export type { ClosedEvent, StateEvent, WindowEvent } from "./events";
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* 原生调用的回执。
|
|
@@ -63,10 +63,10 @@ export type {
|
|
|
63
63
|
* (`state()` / `getZoom()` / `getBounds()`)。
|
|
64
64
|
*/
|
|
65
65
|
export type NativeAck = {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
/** `0` = 已投递给 UI 线程;负数 = 当场被拒(窗口已销毁、参数越界等) */
|
|
67
|
+
rc: number;
|
|
68
|
+
/** 失败原因;成功时恒为空串 */
|
|
69
|
+
err: string;
|
|
70
70
|
};
|
|
71
71
|
|
|
72
72
|
/**
|
|
@@ -76,314 +76,376 @@ export type NativeAck = {
|
|
|
76
76
|
* 成功时读它只会拿到上一次的残留——显示出来就是一个假故障。
|
|
77
77
|
*/
|
|
78
78
|
export function ackOf(shell: Shell, rc: number): NativeAck {
|
|
79
|
-
|
|
79
|
+
return { rc, err: rc === 0 ? "" : shell.lastError() };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export type Shell = {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
83
|
+
/** 创建窗口并加载 URL。返回码 0 表示成功;失败原因见 lastError() */
|
|
84
|
+
start(): StartResult;
|
|
85
|
+
/** 宿 → 页面:投递 JSON 文本(单向,不等结果) */
|
|
86
|
+
post(json: string): number;
|
|
87
|
+
setTitle(title: string): number;
|
|
88
|
+
/**
|
|
89
|
+
* 读回窗口标题的**真实值**(原生 `GetWindowTextW`)。窗口不存在时返回 `null`。
|
|
90
|
+
*
|
|
91
|
+
* 与 `setTitle()` 的 `rc` 不是一回事:`rc === 0` 只说明消息投递到了 UI 线程,
|
|
92
|
+
* 而 `SetWindowTextW` 可能因为窗口正在销毁而失败。一次写之后读一次,才能区分
|
|
93
|
+
* 「投递成功」与「效果发生」。
|
|
94
|
+
*
|
|
95
|
+
* **读的是窗口,不是输入缓存。** `setTitle()` 不更新任何 TS 侧镜像,所以这里
|
|
96
|
+
* 拿到的一定是窗口现在的标题;空标题返回 `""`,与「窗口不存在」的 `null` 分开。
|
|
97
|
+
*/
|
|
98
|
+
getTitle(): string | null;
|
|
99
|
+
minimize(): number;
|
|
100
|
+
toggleMaximize(): number;
|
|
101
|
+
close(): number;
|
|
102
|
+
/** 由页面 pointerdown 触发原生拖拽;保留贴边分屏 */
|
|
103
|
+
drag(): number;
|
|
104
|
+
/**
|
|
105
|
+
* 由页面触发的边框缩放(进入 Windows 的模态缩放循环,鼠标被系统接管)。
|
|
106
|
+
*
|
|
107
|
+
* **入参是 `RESIZE_EDGE` 的 HT* 值(10–17)**,不是内部数组下标。
|
|
108
|
+
*
|
|
109
|
+
* 0.5.x 的实现把入参直传给原生 `shell_begin_resize()`,而原生收的是
|
|
110
|
+
* `codes[8]` 的**数组下标 0–7**——两者的映射是 `HT*` 常量在 Win32 里的定义顺序,
|
|
111
|
+
* 既不是恒等也不是「减 10」(`HTTOPLEFT`=13 在下标 4、`HTBOTTOM`=15 在下标 3)。
|
|
112
|
+
* 于是照着注释传 `RESIZE_EDGE.LEFT`(=10)会被 `edge > 7` 当场拒掉,
|
|
113
|
+
* `rc=-1`,外部表现是「点了没反应」,而门禁全绿。
|
|
114
|
+
*
|
|
115
|
+
* 0.6.0 起由本方法在 TS 层查表转换,原生 DLL 不需要重编。非法值仍然返回 -1。
|
|
116
|
+
*/
|
|
117
|
+
beginResize(edge: ResizeEdge): number;
|
|
118
|
+
/** 最小尺寸,逻辑像素 */
|
|
119
|
+
setMinSize(width: number, height: number): void;
|
|
120
|
+
/**
|
|
121
|
+
* 读回**生效中**的最小尺寸(逻辑像素);窗口不存在时返回 `null`。
|
|
122
|
+
*
|
|
123
|
+
* 这个值不是「最后一次 set 的参数」,而是 `WM_GETMINMAXINFO` 真正会用的那一个
|
|
124
|
+
* ——`setMinSize(0, 0)` 对非正值是忽略的(保持旧值),所以读回来的可能与你刚传的
|
|
125
|
+
* 不同。这正是它比 `setMinSize()` 的 `void` 返回值多出来的信息。
|
|
126
|
+
*/
|
|
127
|
+
getMinSize(): WindowSize | null;
|
|
128
|
+
/**
|
|
129
|
+
* 显示窗口。配合 `FLAG_DEFER_SHOW` 使用:页面就绪后再调用,消除首帧闪烁。
|
|
130
|
+
* 未启用延迟显示时是幂等 no-op。
|
|
131
|
+
*/
|
|
132
|
+
show(): number;
|
|
133
|
+
/**
|
|
134
|
+
* 设置 AppUserModelID(任务栏分组与图标归属)。
|
|
135
|
+
* 只在 `start()` 之前调用才对本窗口生效。
|
|
136
|
+
*/
|
|
137
|
+
setAppId(appId: string): number;
|
|
138
|
+
/**
|
|
139
|
+
* 弹出系统文件夹选择框,返回选中路径;用户取消返回 null。
|
|
140
|
+
*
|
|
141
|
+
* 直接调 shell32,不起子进程、不落临时脚本——比「spawn powershell 跑 WinForms」
|
|
142
|
+
* 快一个数量级,也不受执行策略限制。
|
|
143
|
+
*
|
|
144
|
+
* **同步阻塞**:SHBrowseForFolder 自带模态循环,调用期间调用线程被占住
|
|
145
|
+
* (实测:Bun 主线程上的定时器在此期间不会触发)。同理,被阻塞的调用链
|
|
146
|
+
* 无法取消或超时。需要并发时把它放到 Worker 里调用。
|
|
147
|
+
*/
|
|
148
|
+
pickFolder(title?: string): string | null;
|
|
149
|
+
state(): WindowState;
|
|
150
|
+
/**
|
|
151
|
+
* 窗口是否仍然存在。
|
|
152
|
+
*
|
|
153
|
+
* `state()` 无法区分「未启动」与「已销毁」(两者都返回全 false),
|
|
154
|
+
* 宿主需要这个信号才能判断窗口是否已被用户关掉,进而回收自己的进程——
|
|
155
|
+
* 窗口销毁后原生 UI 线程会返回,但宿主进程与它起的本地服务不会自己退出。
|
|
156
|
+
*
|
|
157
|
+
* 想要「等窗口关闭」而不是「每一拍问一次」时,用 `pollEvents()` 的
|
|
158
|
+
* `EVENT_CLOSED`(或 `bun-app-window/events` 的 `attachEventPump()`)。
|
|
159
|
+
* 两者不等价:`isAlive()` 读的是此刻的 `g_webview` 是否为空,而 `CLOSED`
|
|
160
|
+
* 是原生侧在 teardown 全部做完之后写进队列的显式终态。
|
|
161
|
+
*/
|
|
162
|
+
isAlive(): boolean;
|
|
163
|
+
/**
|
|
164
|
+
* 批量拉取窗口生命周期事件(`EVENT_STATE` / `EVENT_CLOSED`)。
|
|
165
|
+
*
|
|
166
|
+
* **消费式**:读走的记录不会再来。所以一个进程只应有一个消费者——
|
|
167
|
+
* 要么用 `bun-app-window/events` 的 `attachEventPump()`,要么自己按节奏调
|
|
168
|
+
* 这个方法,别两者都来:两个消费者会各自拿到一部分,谁也看不全。
|
|
169
|
+
*
|
|
170
|
+
* 同步、零分配以外的副作用;`max` 是记录条数,内部夹在
|
|
171
|
+
* `1..EVENT_QUEUE_CAPACITY`。窗口未启动时返回空数组。
|
|
172
|
+
*
|
|
173
|
+
* ```ts
|
|
174
|
+
* for (const e of shell.pollEvents()) {
|
|
175
|
+
* if (e.type === EVENT_CLOSED) break;
|
|
176
|
+
* if (e.maximized) { ... }
|
|
177
|
+
* }
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
pollEvents(max?: number): WindowEvent[];
|
|
181
|
+
/** 最近一次失败的原因;无错误时为空串。走拷贝式读出,不受并发写影响 */
|
|
182
|
+
lastError(): string;
|
|
183
|
+
|
|
184
|
+
// ---- 应用化 ----
|
|
185
|
+
/**
|
|
186
|
+
* 关掉指定的浏览器行为(`BROWSER_UI_*` 按位或)。运行时生效。
|
|
187
|
+
*
|
|
188
|
+
* 一般通过 `ShellOptions.browserUi` 声明即可;这个方法用于运行中切换
|
|
189
|
+
* (例如调试模式临时放开 `Ctrl+F`)。
|
|
190
|
+
*/
|
|
191
|
+
setBrowserUi(mask: number): number;
|
|
192
|
+
/**
|
|
193
|
+
* 已请求关闭的浏览器行为位掩码(`BROWSER_UI_*` 的按位或)。
|
|
194
|
+
*
|
|
195
|
+
* **累积语义**:`setBrowserUi()` 只有「关」的方向,没有恢复分支(它内部的
|
|
196
|
+
* `apply_browser_ui()` 对 `mask === 0` 直接 return)。所以
|
|
197
|
+
* `setBrowserUi(BROWSER_UI_MENU)` 之后再 `setBrowserUi(BROWSER_UI_KEYS)`,
|
|
198
|
+
* 结果是**两者都关**,这里读回来是 `MENU | KEYS`。
|
|
199
|
+
*
|
|
200
|
+
* 窗口未启动时返回 `0`——那是一个准确的答案(没有窗口,没有任何行为被关),
|
|
201
|
+
* 不是「读不到」。这一点与 `getTitle()` / `getMinSize()` 的 `null` 不对称,
|
|
202
|
+
* 因为它们在没有窗口时给不出有意义的字符串。
|
|
203
|
+
*/
|
|
204
|
+
getBrowserUi(): number;
|
|
205
|
+
/**
|
|
206
|
+
* 在屏幕坐标弹出**原生**上下文菜单。
|
|
207
|
+
*
|
|
208
|
+
* 关掉 WebView2 默认右键菜单后,右键就成了「点了没反应」,那也不像原生应用。
|
|
209
|
+
* 原生应用右键是有菜单的,只是菜单属于应用:
|
|
210
|
+
*
|
|
211
|
+
* ```ts
|
|
212
|
+
* document.addEventListener("contextmenu", (e) => {
|
|
213
|
+
* e.preventDefault();
|
|
214
|
+
* fetch("/api/menu", { method: "POST", body: JSON.stringify({ x: e.screenX, y: e.screenY }) });
|
|
215
|
+
* });
|
|
216
|
+
* ```
|
|
217
|
+
*
|
|
218
|
+
* `items` 用行描述,`id|label` 每行一项,单独 `-` 是分隔线:
|
|
219
|
+
* `"open|打开题库\nrefresh|刷新\n-\nquit|退出"`。
|
|
220
|
+
* 选中后通过 shell-msg 推回 `{ t: "menu", id: "<id>" }`。
|
|
221
|
+
*/
|
|
222
|
+
showContextMenu(x: number, y: number, items: string): number;
|
|
223
|
+
|
|
224
|
+
// ---- 窗口位置 ----
|
|
225
|
+
/**
|
|
226
|
+
* 当前窗口矩形,格式 "x,y,w,h,maximized,dpi"(物理像素)。
|
|
227
|
+
* 最大化时返回的是「还原后」的矩形。窗口未启动时返回空串。
|
|
228
|
+
*
|
|
229
|
+
* 这是原生那一行文本的**直传**:不解析、不校验,字符串长什么样就什么样。
|
|
230
|
+
* 要可用的结构就用 `getRect()`——两者读的是同一个原生符号,只是后者把
|
|
231
|
+
* 同一份文本按判据解析了一遍。要连持久化一起办,用 `bun-app-window/state`
|
|
232
|
+
* 的 `capturePlacement()`。
|
|
233
|
+
*/
|
|
234
|
+
getBounds(): string;
|
|
235
|
+
/**
|
|
236
|
+
* 当前窗口矩形的**结构化**读数;读不到或数据不可用时返回 `null`。
|
|
237
|
+
*
|
|
238
|
+
* 与 `getBounds()` 的分工:后者是逃生舱(原生原文直传),本方法是正式出口。
|
|
239
|
+
* 判据与 `parseBounds()` 同源,返回 `null` 的三种情形见那里——不只是
|
|
240
|
+
* 「窗口没开」,还包括**最小化时的 Win32 哨兵坐标**(形状合法但位置是假的,
|
|
241
|
+
* 详见 `MINIMIZED_SENTINEL` 的说明)。
|
|
242
|
+
*
|
|
243
|
+
* ```ts
|
|
244
|
+
* const r = shell.getRect();
|
|
245
|
+
* if (r) console.log(`${r.width}×${r.height} @ ${r.x},${r.y} dpi=${r.dpi}`);
|
|
246
|
+
* ```
|
|
247
|
+
*
|
|
248
|
+
* 为什么不让 `state()` 带矩形:`state()` 是廉价位掩码,加一次 FFI 字符串读
|
|
249
|
+
* 与解析会改变它的成本模型,而 `WindowState` 的形状变更本身就是破坏性改动。
|
|
250
|
+
* 需要「最大化标记 + 矩形」一起判时,本方法返回的 `maximized` 与 `x/y/width/height`
|
|
251
|
+
* 来自**同一次**原生读取,不存在两拍之间的撕裂。
|
|
252
|
+
*/
|
|
253
|
+
getRect(): WindowRect | null;
|
|
254
|
+
/**
|
|
255
|
+
* 恢复窗口矩形。savedDpi 是保存时的 DPI,用于跨缩放比例换算;
|
|
256
|
+
* 原生侧还会校验矩形是否仍在某块屏上,不在则居中。
|
|
257
|
+
*
|
|
258
|
+
* 一般不需要直接调用——用 bun-app-window/state 的 restorePlacement()。
|
|
259
|
+
*/
|
|
260
|
+
restoreBounds(x: number, y: number, width: number, height: number, savedDpi: number, maximized: boolean): number;
|
|
261
|
+
|
|
262
|
+
// ---- 输入与视图 ----
|
|
263
|
+
/**
|
|
264
|
+
* 页面缩放倍率(0.25 ~ 3.0)。也支持用户键盘操作:Ctrl± / Ctrl+0 / Ctrl+滚轮。
|
|
265
|
+
* 生效是异步的(投到 UI 线程),getZoom() 读的是缓存值。
|
|
266
|
+
*/
|
|
267
|
+
setZoom(factor: number): number;
|
|
268
|
+
/** 当前缩放倍率(缓存值) */
|
|
269
|
+
getZoom(): number;
|
|
270
|
+
/** 重新加载页面(等同 Ctrl+R / F5) */
|
|
271
|
+
reload(): number;
|
|
272
|
+
/** 打开开发者工具(等同 F12) */
|
|
273
|
+
openDevTools(): number;
|
|
274
|
+
/** 切换全屏(等同 F11);全屏与最大化不同,连边框与任务栏一起让出 */
|
|
275
|
+
toggleFullscreen(): number;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 运行时切换窗口形态(ADR 0019)。`frameless = false` 让系统标题栏回来。
|
|
279
|
+
*
|
|
280
|
+
* **只在创建期给了 `FLAG_FRAMELESS` 时可用**:带边框起点是 webview 自建的窗口,
|
|
281
|
+
* 我们没有它的 `WM_NCCALCSIZE` 控制权。那种情况下返回 -1,理由在 `lastError()`。
|
|
282
|
+
*
|
|
283
|
+
* 生效是异步的(投到 UI 线程)。确认方式两条:`isFrameless()` 读缓存,或页面
|
|
284
|
+
* 监听 `shell-msg` 的 `win` 消息——它的 `frameless` 字段会在切换后推一次。
|
|
285
|
+
* 全屏期间调用会被拒:`toggleFullscreen` 自带样式位与矩形的缓存,两者同时改
|
|
286
|
+
* 会让退出全屏还原成一个混合体。
|
|
287
|
+
*/
|
|
288
|
+
setFrameless(frameless: boolean): number;
|
|
289
|
+
/** 当前运行时形态。读缓存,无窗口调用;未启动时回创建期形态 */
|
|
290
|
+
isFrameless(): boolean;
|
|
291
|
+
|
|
292
|
+
/** 停止并释放窗口(可重复调用) */
|
|
293
|
+
stop(): void;
|
|
294
|
+
|
|
295
|
+
readonly started: boolean;
|
|
296
|
+
readonly libInfo: NativeLibInfo;
|
|
297
|
+
/** 逃生舱:直接访问 dlopen 出来的符号 */
|
|
298
|
+
readonly symbols: NativeSymbols;
|
|
299
|
+
|
|
300
|
+
// 排障面(hittest / metrics / dpi / selfBase / selfPath)已移到独立入口
|
|
301
|
+
// import { debugOf } from "bun-app-window/debug"
|
|
302
|
+
// 目的:公共面只承诺产品向能力。把排障面留在主入口,等于把它也变成契约,
|
|
303
|
+
// 将来改一次诊断输出就成了破坏性变更。
|
|
257
304
|
};
|
|
258
305
|
|
|
259
306
|
export async function createShell(opts: ShellOptions): Promise<Shell> {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
307
|
+
if (!opts.url) throw new Error("createShell: url is required");
|
|
308
|
+
|
|
309
|
+
const libInfo = await resolveNativeLib({
|
|
310
|
+
...(opts.nativeLibPath === undefined ? {} : { nativeLibPath: opts.nativeLibPath }),
|
|
311
|
+
...(opts.appName === undefined ? {} : { appName: opts.appName }),
|
|
312
|
+
...(opts.embedded === undefined ? {} : { embedded: opts.embedded }),
|
|
313
|
+
});
|
|
314
|
+
const { symbols } = openNativeLib(libInfo.path);
|
|
315
|
+
|
|
316
|
+
let started = false;
|
|
317
|
+
let stopped = false;
|
|
318
|
+
|
|
319
|
+
// 拷贝式读出:shell_error() 返回共享缓冲区的指针,解锁后可能被别的线程改写
|
|
320
|
+
const lastError = () => readBuffer((p, len) => symbols.shell_error_text(p, len), 512);
|
|
321
|
+
// 原生那一行 "x,y,w,h,maximized,dpi" 最长也不过几十字节,256 是余量
|
|
322
|
+
const readBounds = () => readBuffer((p, len) => symbols.shell_get_bounds(p, len), 256);
|
|
323
|
+
|
|
324
|
+
const shell: Shell = {
|
|
325
|
+
start(): StartResult {
|
|
326
|
+
if (started) return { rc: -1, error: "shell already started" };
|
|
327
|
+
const rc = symbols.shell_start_ex(
|
|
328
|
+
cstr(opts.url),
|
|
329
|
+
cstr(opts.title ?? "bun-app-window"),
|
|
330
|
+
opts.width ?? 1000,
|
|
331
|
+
opts.height ?? 680,
|
|
332
|
+
opts.flags ?? 0,
|
|
333
|
+
);
|
|
334
|
+
started = rc === 0;
|
|
335
|
+
if (started && opts.minWidth && opts.minHeight) {
|
|
336
|
+
symbols.shell_set_min_size(opts.minWidth, opts.minHeight);
|
|
337
|
+
}
|
|
338
|
+
if (started && opts.browserUi) {
|
|
339
|
+
// 放在 start 之后:需要 webview 已存在。这些是 UI 行为开关,
|
|
340
|
+
// 运行时改即时生效,不涉及渲染,不必赶在 navigate 之前。
|
|
341
|
+
symbols.shell_set_browser_ui(opts.browserUi);
|
|
342
|
+
}
|
|
343
|
+
return { rc, error: rc === 0 ? "" : lastError() };
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
post: (json) => symbols.shell_post(cstr(json)),
|
|
347
|
+
setTitle: (title) => symbols.shell_title(cstr(title)),
|
|
348
|
+
getTitle(): string | null {
|
|
349
|
+
// 1024 B 够放约 340 个汉字;原生侧装不下会回 -1 而不是给半个字符
|
|
350
|
+
const buf = new Uint8Array(1024);
|
|
351
|
+
const n = symbols.shell_get_title(ptr(buf), buf.length);
|
|
352
|
+
// n < 0 覆盖「窗口不存在」与「装不下」。空标题(n === 0)是 "",不是 null。
|
|
353
|
+
if (n < 0) return null;
|
|
354
|
+
return new TextDecoder().decode(buf.subarray(0, n));
|
|
355
|
+
},
|
|
356
|
+
minimize: () => symbols.shell_minimize(),
|
|
357
|
+
toggleMaximize: () => symbols.shell_toggle_maximize(),
|
|
358
|
+
close: () => symbols.shell_close(),
|
|
359
|
+
drag: () => symbols.shell_drag(),
|
|
360
|
+
beginResize: (edge) => symbols.shell_begin_resize(resizeIndex(edge)),
|
|
361
|
+
setMinSize: (w, h) => symbols.shell_set_min_size(w, h),
|
|
362
|
+
getMinSize(): WindowSize | null {
|
|
363
|
+
// "320,200" 这种形态最长几十字节
|
|
364
|
+
const buf = new Uint8Array(64);
|
|
365
|
+
const n = symbols.shell_get_min_size(ptr(buf), buf.length);
|
|
366
|
+
if (n < 0) return null;
|
|
367
|
+
return parseSize(new TextDecoder().decode(buf.subarray(0, n)));
|
|
368
|
+
},
|
|
369
|
+
show: () => symbols.shell_show(),
|
|
370
|
+
setAppId: (appId) => symbols.shell_set_app_id(cstr(appId)),
|
|
371
|
+
|
|
372
|
+
pickFolder(title?: string): string | null {
|
|
373
|
+
const buf = new Uint8Array(4096);
|
|
374
|
+
const rc = symbols.shell_pick_folder(cstr(title ?? "选择文件夹"), ptr(buf), buf.length);
|
|
375
|
+
if (rc !== 0) return null; // 1 = 取消,-1 = 失败
|
|
376
|
+
const end = buf.indexOf(0);
|
|
377
|
+
return new TextDecoder().decode(buf.subarray(0, end < 0 ? buf.length : end));
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
state(): WindowState {
|
|
381
|
+
const raw = symbols.shell_get_state();
|
|
382
|
+
return {
|
|
383
|
+
raw,
|
|
384
|
+
maximized: (raw & STATE_MAXIMIZED) !== 0,
|
|
385
|
+
minimized: (raw & STATE_MINIMIZED) !== 0,
|
|
386
|
+
focused: (raw & STATE_FOCUSED) !== 0,
|
|
387
|
+
dark: symbols.shell_is_dark() !== 0,
|
|
388
|
+
};
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
isAlive: () => symbols.shell_is_alive() !== 0,
|
|
392
|
+
|
|
393
|
+
pollEvents(max = 64): WindowEvent[] {
|
|
394
|
+
// 原生侧环容量是 128,一次要更多没有意义;下限 1 免得 0 变成「永远拉不到」
|
|
395
|
+
const cap = Math.max(1, Math.min(Math.trunc(max), EVENT_QUEUE_CAPACITY));
|
|
396
|
+
const buf = new Uint8Array(cap * EVENT_RECORD_BYTES);
|
|
397
|
+
const n = symbols.shell_poll_events(ptr(buf), cap);
|
|
398
|
+
return n > 0 ? decodeEvents(buf, n) : [];
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
lastError,
|
|
402
|
+
|
|
403
|
+
setBrowserUi: (mask) => symbols.shell_set_browser_ui(mask),
|
|
404
|
+
// 累积值,0 是「什么都没关」——不是「读不到」,所以不套 null
|
|
405
|
+
getBrowserUi: () => symbols.shell_get_browser_ui(),
|
|
406
|
+
showContextMenu: (x, y, items) => symbols.shell_show_app_menu(x, y, cstr(items)),
|
|
407
|
+
|
|
408
|
+
getBounds: readBounds,
|
|
409
|
+
// 与 getBounds 读同一个原生符号,区别只在「解析过没有」。这条链不能省。
|
|
410
|
+
getRect: () => parseBounds(readBounds()),
|
|
411
|
+
restoreBounds: (x, y, w, h, savedDpi, maximized) =>
|
|
412
|
+
symbols.shell_restore_bounds(x, y, w, h, savedDpi, maximized ? 1 : 0),
|
|
413
|
+
|
|
414
|
+
setZoom: (factor) => symbols.shell_set_zoom(factor),
|
|
415
|
+
getZoom: () => symbols.shell_get_zoom(),
|
|
416
|
+
reload: () => symbols.shell_reload(),
|
|
417
|
+
openDevTools: () => symbols.shell_devtools(),
|
|
418
|
+
toggleFullscreen: () => symbols.shell_toggle_fullscreen(),
|
|
419
|
+
setFrameless: (frameless) => symbols.shell_set_frameless(frameless ? 1 : 0),
|
|
420
|
+
isFrameless: () => symbols.shell_is_frameless() !== 0,
|
|
421
|
+
|
|
422
|
+
stop() {
|
|
423
|
+
if (stopped) return;
|
|
424
|
+
stopped = true;
|
|
425
|
+
started = false;
|
|
426
|
+
try {
|
|
427
|
+
symbols.shell_stop();
|
|
428
|
+
} catch {
|
|
429
|
+
// 窗口已销毁时无需处理
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
get started() {
|
|
434
|
+
return started;
|
|
435
|
+
},
|
|
436
|
+
libInfo,
|
|
437
|
+
symbols,
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
return shell;
|
|
379
441
|
}
|
|
380
442
|
|
|
381
443
|
/** 语法糖:创建并启动;失败直接抛错 */
|
|
382
444
|
export async function openShell(opts: ShellOptions): Promise<Shell> {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
445
|
+
const shell = await createShell(opts);
|
|
446
|
+
const { rc, error } = shell.start();
|
|
447
|
+
if (rc !== 0) {
|
|
448
|
+
throw new Error(`shell_start_ex failed (rc=${rc}): ${error || "unknown"}`);
|
|
449
|
+
}
|
|
450
|
+
return shell;
|
|
389
451
|
}
|