bun-app-window 0.3.0 → 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 +67 -2
- package/README.md +126 -4
- package/README.zh-CN.md +111 -4
- package/README.zh-TW.md +112 -4
- 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/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { cstr, openNativeLib, readBuffer } from "./native";
|
|
|
24
24
|
import { ptr } from "bun:ffi";
|
|
25
25
|
import type { NativeSymbols } from "./native";
|
|
26
26
|
import { resolveNativeLib } from "./resolve";
|
|
27
|
+
import { EVENT_QUEUE_CAPACITY, EVENT_RECORD_BYTES, decodeEvents, type WindowEvent } from "./events";
|
|
27
28
|
import {
|
|
28
29
|
STATE_FOCUSED,
|
|
29
30
|
STATE_MAXIMIZED,
|
|
@@ -41,6 +42,11 @@ export { serveOnSafePort, isRestrictedPort } from "./port";
|
|
|
41
42
|
export type { ServeOptions } from "./port";
|
|
42
43
|
export { cstr, readCStr } from "./native";
|
|
43
44
|
export type { NativeSymbols, NativeLib } from "./native";
|
|
45
|
+
export type {
|
|
46
|
+
ClosedEvent,
|
|
47
|
+
StateEvent,
|
|
48
|
+
WindowEvent,
|
|
49
|
+
} from "./events";
|
|
44
50
|
|
|
45
51
|
export type Shell = {
|
|
46
52
|
/** 创建窗口并加载 URL。返回码 0 表示成功;失败原因见 lastError() */
|
|
@@ -85,8 +91,31 @@ export type Shell = {
|
|
|
85
91
|
* `state()` 无法区分「未启动」与「已销毁」(两者都返回全 false),
|
|
86
92
|
* 宿主需要这个信号才能判断窗口是否已被用户关掉,进而回收自己的进程——
|
|
87
93
|
* 窗口销毁后原生 UI 线程会返回,但宿主进程与它起的本地服务不会自己退出。
|
|
94
|
+
*
|
|
95
|
+
* 想要「等窗口关闭」而不是「每一拍问一次」时,用 `pollEvents()` 的
|
|
96
|
+
* `EVENT_CLOSED`(或 `bun-app-window/events` 的 `attachEventPump()`)。
|
|
97
|
+
* 两者不等价:`isAlive()` 读的是此刻的 `g_webview` 是否为空,而 `CLOSED`
|
|
98
|
+
* 是原生侧在 teardown 全部做完之后写进队列的显式终态。
|
|
88
99
|
*/
|
|
89
100
|
isAlive(): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* 批量拉取窗口生命周期事件(`EVENT_STATE` / `EVENT_CLOSED`)。
|
|
103
|
+
*
|
|
104
|
+
* **消费式**:读走的记录不会再来。所以一个进程只应有一个消费者——
|
|
105
|
+
* 要么用 `bun-app-window/events` 的 `attachEventPump()`,要么自己按节奏调
|
|
106
|
+
* 这个方法,别两者都来:两个消费者会各自拿到一部分,谁也看不全。
|
|
107
|
+
*
|
|
108
|
+
* 同步、零分配以外的副作用;`max` 是记录条数,内部夹在
|
|
109
|
+
* `1..EVENT_QUEUE_CAPACITY`。窗口未启动时返回空数组。
|
|
110
|
+
*
|
|
111
|
+
* ```ts
|
|
112
|
+
* for (const e of shell.pollEvents()) {
|
|
113
|
+
* if (e.type === EVENT_CLOSED) break;
|
|
114
|
+
* if (e.maximized) { ... }
|
|
115
|
+
* }
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
pollEvents(max?: number): WindowEvent[];
|
|
90
119
|
/** 最近一次失败的原因;无错误时为空串。走拷贝式读出,不受并发写影响 */
|
|
91
120
|
lastError(): string;
|
|
92
121
|
|
|
@@ -242,6 +271,14 @@ export async function createShell(opts: ShellOptions): Promise<Shell> {
|
|
|
242
271
|
|
|
243
272
|
isAlive: () => symbols.shell_is_alive() !== 0,
|
|
244
273
|
|
|
274
|
+
pollEvents(max = 64): WindowEvent[] {
|
|
275
|
+
// 原生侧环容量是 128,一次要更多没有意义;下限 1 免得 0 变成「永远拉不到」
|
|
276
|
+
const cap = Math.max(1, Math.min(Math.trunc(max), EVENT_QUEUE_CAPACITY));
|
|
277
|
+
const buf = new Uint8Array(cap * EVENT_RECORD_BYTES);
|
|
278
|
+
const n = symbols.shell_poll_events(ptr(buf), cap);
|
|
279
|
+
return n > 0 ? decodeEvents(buf, n) : [];
|
|
280
|
+
},
|
|
281
|
+
|
|
245
282
|
lastError,
|
|
246
283
|
|
|
247
284
|
setBrowserUi: (mask) => symbols.shell_set_browser_ui(mask),
|
package/src/shell/native.ts
CHANGED
|
@@ -10,8 +10,8 @@ import type { Pointer } from "bun:ffi";
|
|
|
10
10
|
/**
|
|
11
11
|
* 打开 libshell.dll。
|
|
12
12
|
*
|
|
13
|
-
* 导出面说明:DLL 实际导出
|
|
14
|
-
* TS 侧不声明、也不对外承诺(故下方声明
|
|
13
|
+
* 导出面说明:DLL 实际导出 34 个符号,其中 `shell_start` 是 1.x 遗留入口,
|
|
14
|
+
* TS 侧不声明、也不对外承诺(故下方声明 33 个);`shell_debug_*` / `shell_self_*` 属排障面。
|
|
15
15
|
*/
|
|
16
16
|
export function openNativeLib(path: string) {
|
|
17
17
|
return dlopen(path, {
|
|
@@ -29,6 +29,8 @@ export function openNativeLib(path: string) {
|
|
|
29
29
|
shell_set_min_size: { args: [FFIType.i32, FFIType.i32], returns: FFIType.void },
|
|
30
30
|
shell_get_state: { args: [], returns: FFIType.i32 },
|
|
31
31
|
shell_is_alive: { args: [], returns: FFIType.i32 },
|
|
32
|
+
// 窗口生命周期事件:批量拉取(消费式),capacity 是记录条数
|
|
33
|
+
shell_poll_events: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
32
34
|
shell_stop: { args: [], returns: FFIType.void },
|
|
33
35
|
shell_error: { args: [], returns: FFIType.ptr },
|
|
34
36
|
// 系统集成面
|
package/src/shell/state.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { EVENT_STATE, attachEventPump, type EventPump, unrefTimer } from "./events";
|
|
4
5
|
import type { Shell } from "./index";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -14,6 +15,21 @@ import type { Shell } from "./index";
|
|
|
14
15
|
* - DPI 变化:存的是物理像素 + 当时的 DPI,恢复时按比例换算
|
|
15
16
|
* - 显示器不在了:矩形要与某块工作区有足够交叠才算可用,否则居中
|
|
16
17
|
* - 最大化状态:存「还原后」的矩形,而不是整屏尺寸
|
|
18
|
+
*
|
|
19
|
+
* ## 为什么现在是事件驱动,不是轮询
|
|
20
|
+
*
|
|
21
|
+
* 这个模块曾经每 700 ms 调一次 `getBounds()`。理由是「原生侧的窗口状态推送是发给
|
|
22
|
+
* **页面**的(shell-msg),宿主拿不到」。0.4.0 起那个理由不成立了:`shell_poll_events`
|
|
23
|
+
* 给了宿主一条自己的通道。
|
|
24
|
+
*
|
|
25
|
+
* 换成事件驱动之后,两件事同时变好:
|
|
26
|
+
* - 窗口静止时**一次 FFI 都不发生**(轮询时代是每秒 1.4 次)
|
|
27
|
+
* - 拖动期间不再每帧写盘。事件到一条就即时 `getBounds()` 存快照(微秒级),
|
|
28
|
+
* 但**写盘**攒到停手后 `debounceMs` 才做一次
|
|
29
|
+
*
|
|
30
|
+
* 快照与写盘分离是有意的:窗口关闭后原生侧 `getBounds()` 立刻返回空串
|
|
31
|
+
* (`format_bounds` 里 `!g_hwnd` 直接返回 0),若等到防抖到点才去读,最后那次
|
|
32
|
+
* 位置变化就永久丢了——用户「拖完立刻点 X」是最常见的关闭方式。
|
|
17
33
|
*/
|
|
18
34
|
|
|
19
35
|
export type WindowPlacement = {
|
|
@@ -28,8 +44,10 @@ export type WindowPlacement = {
|
|
|
28
44
|
};
|
|
29
45
|
|
|
30
46
|
const DEFAULTS = {
|
|
31
|
-
/**
|
|
32
|
-
|
|
47
|
+
/** 收到变化事件后攒多久再写盘。拖动期间事件每帧一条,攒到停手写一次 */
|
|
48
|
+
debounceMs: 300,
|
|
49
|
+
/** 自建事件泵(未由调用方提供)时的拉取节奏 */
|
|
50
|
+
pumpMs: 16,
|
|
33
51
|
} as const;
|
|
34
52
|
|
|
35
53
|
/** 把原生返回的一行文本解析成 WindowPlacement;格式不对返回 null。 */
|
|
@@ -131,31 +149,60 @@ export async function savePlacement(file: string, p: WindowPlacement): Promise<v
|
|
|
131
149
|
export type PlacementStore = {
|
|
132
150
|
/** 立即抓一次并写盘(关闭前调用) */
|
|
133
151
|
flush(): Promise<void>;
|
|
134
|
-
/**
|
|
152
|
+
/** 停止拉取事件与防抖定时器。不清盘——最后写入的值保留 */
|
|
135
153
|
dispose(): void;
|
|
136
|
-
/** 当前已知的 placement
|
|
154
|
+
/** 当前已知的 placement(最近一次快照) */
|
|
137
155
|
readonly current: WindowPlacement | null;
|
|
138
156
|
};
|
|
139
157
|
|
|
158
|
+
export type PlacementStoreOptions = {
|
|
159
|
+
/**
|
|
160
|
+
* 变化后攒多久再写盘。默认 300 ms。
|
|
161
|
+
*
|
|
162
|
+
* 设成 0 表示「每条事件都立刻写」——只适合测试,真实应用会给磁盘造成
|
|
163
|
+
* 每秒几十次的写入。
|
|
164
|
+
*/
|
|
165
|
+
debounceMs?: number;
|
|
166
|
+
/**
|
|
167
|
+
* 复用宿主已有的事件泵。
|
|
168
|
+
*
|
|
169
|
+
* **`shell.pollEvents()` 是消费式的**:一个窗口只该有一个消费者。用
|
|
170
|
+
* `./app` 的骨架时传 `{ events: app.events }`,别再让它自己起一个——
|
|
171
|
+
* 两个泵会各自拿到一部分事件,位置持久化会随机漏掉一些变化。
|
|
172
|
+
*
|
|
173
|
+
* 不给时本模块自建一个(内部泵定时器 unref,不阻止进程退出)。
|
|
174
|
+
*/
|
|
175
|
+
events?: EventPump;
|
|
176
|
+
/** 自建事件泵时的拉取节奏,默认 16 ms。给了 `events` 时忽略 */
|
|
177
|
+
pumpMs?: number;
|
|
178
|
+
};
|
|
179
|
+
|
|
140
180
|
/**
|
|
141
|
-
*
|
|
181
|
+
* 订阅窗口状态变化并在停手后写盘。
|
|
142
182
|
*
|
|
143
|
-
*
|
|
144
|
-
* 拿不到;加一条原生→宿主的回调通道要多一套生命周期管理,而 GetWindowRect 是
|
|
145
|
-
* 微秒级调用,700 ms 一次的代价可以忽略。
|
|
183
|
+
* 变化检测按内容比较:窗口静止时不产生任何磁盘写入,也不产生任何调度。
|
|
146
184
|
*
|
|
147
|
-
*
|
|
185
|
+
* 首次 `attach` 会立刻抓一次快照并写盘——程序若在用户操作前崩溃,也还有
|
|
186
|
+
* 初始位置可恢复。
|
|
148
187
|
*/
|
|
149
188
|
export function attachPlacementStore(
|
|
150
189
|
shell: Shell,
|
|
151
190
|
file: string,
|
|
152
|
-
opts:
|
|
191
|
+
opts: PlacementStoreOptions = {},
|
|
153
192
|
): PlacementStore {
|
|
154
|
-
const
|
|
193
|
+
const debounceMs = Math.max(0, opts.debounceMs ?? DEFAULTS.debounceMs);
|
|
194
|
+
const pump =
|
|
195
|
+
opts.events ??
|
|
196
|
+
attachEventPump(shell, { intervalMs: Math.max(1, opts.pumpMs ?? DEFAULTS.pumpMs) });
|
|
197
|
+
const ownsPump = opts.events === undefined;
|
|
198
|
+
|
|
155
199
|
let last: WindowPlacement | null = null;
|
|
156
200
|
let lastKey = "";
|
|
157
201
|
let disposed = false;
|
|
158
202
|
let pending: Promise<void> = Promise.resolve();
|
|
203
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
204
|
+
/** 已取到快照、但还没写盘的值 */
|
|
205
|
+
let dirty: WindowPlacement | null = null;
|
|
159
206
|
|
|
160
207
|
const same = (a: WindowPlacement, b: WindowPlacement): boolean =>
|
|
161
208
|
a.x === b.x &&
|
|
@@ -165,15 +212,20 @@ export function attachPlacementStore(
|
|
|
165
212
|
a.maximized === b.maximized &&
|
|
166
213
|
a.dpi === b.dpi;
|
|
167
214
|
|
|
168
|
-
|
|
215
|
+
/**
|
|
216
|
+
* 抓一次快照,变了就记进 `dirty`。
|
|
217
|
+
*
|
|
218
|
+
* 与写盘分开:这一步是微秒级的 FFI 读,必须在窗口还活着的时候做。写盘可以晚。
|
|
219
|
+
*/
|
|
220
|
+
const snapshot = (): void => {
|
|
169
221
|
if (disposed) {
|
|
170
222
|
return;
|
|
171
223
|
}
|
|
172
224
|
const now = capturePlacement(shell);
|
|
225
|
+
// 窗口销毁过程中会短暂读到 0 尺寸,parseBounds 已过滤
|
|
173
226
|
if (!now) {
|
|
174
227
|
return;
|
|
175
228
|
}
|
|
176
|
-
// 窗口销毁过程中会短暂读到 0 尺寸,上面已过滤
|
|
177
229
|
if (last && same(now, last)) {
|
|
178
230
|
return;
|
|
179
231
|
}
|
|
@@ -183,26 +235,68 @@ export function attachPlacementStore(
|
|
|
183
235
|
}
|
|
184
236
|
last = now;
|
|
185
237
|
lastKey = key;
|
|
238
|
+
dirty = now;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const write = (): void => {
|
|
242
|
+
const now = dirty;
|
|
243
|
+
dirty = null;
|
|
244
|
+
if (!now) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
186
247
|
// 串行化写入:并发 rename 同一目标会互相踩
|
|
187
248
|
pending = pending.then(() => savePlacement(file, now)).catch(() => {
|
|
188
|
-
//
|
|
249
|
+
// 写盘失败不该打断应用;下一次变化会重试
|
|
189
250
|
});
|
|
190
251
|
};
|
|
191
252
|
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
253
|
+
const scheduleWrite = (): void => {
|
|
254
|
+
if (disposed) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (timer !== null) {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
}
|
|
260
|
+
timer = setTimeout(() => {
|
|
261
|
+
timer = null;
|
|
262
|
+
write();
|
|
263
|
+
}, debounceMs);
|
|
264
|
+
unrefTimer(timer);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const off = pump.on((e) => {
|
|
268
|
+
if (e.type !== EVENT_STATE) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// 立刻取快照(窗口还活着),但写盘交给防抖
|
|
272
|
+
snapshot();
|
|
273
|
+
scheduleWrite();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// 首次快照:不等到第一条事件就有初始值可恢复
|
|
277
|
+
snapshot();
|
|
278
|
+
write();
|
|
197
279
|
|
|
198
280
|
return {
|
|
199
281
|
async flush(): Promise<void> {
|
|
200
|
-
|
|
282
|
+
if (timer !== null) {
|
|
283
|
+
clearTimeout(timer);
|
|
284
|
+
timer = null;
|
|
285
|
+
}
|
|
286
|
+
snapshot();
|
|
287
|
+
write();
|
|
201
288
|
await pending;
|
|
202
289
|
},
|
|
203
290
|
dispose(): void {
|
|
204
291
|
disposed = true;
|
|
205
|
-
|
|
292
|
+
off();
|
|
293
|
+
if (timer !== null) {
|
|
294
|
+
clearTimeout(timer);
|
|
295
|
+
timer = null;
|
|
296
|
+
}
|
|
297
|
+
if (ownsPump) {
|
|
298
|
+
pump.stop();
|
|
299
|
+
}
|
|
206
300
|
},
|
|
207
301
|
get current(): WindowPlacement | null {
|
|
208
302
|
return last;
|