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.
@@ -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),
@@ -10,8 +10,8 @@ import type { Pointer } from "bun:ffi";
10
10
  /**
11
11
  * 打开 libshell.dll。
12
12
  *
13
- * 导出面说明:DLL 实际导出 33 个符号,其中 `shell_start` 是 1.x 遗留入口,
14
- * TS 侧不声明、也不对外承诺(故下方声明 32 个);`shell_debug_*` / `shell_self_*` 属排障面。
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
  // 系统集成面
@@ -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
- /** 轮询间隔。GetWindowRect 是微秒级,700 ms 足够了 */
32
- intervalMs: 700,
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
- * 为什么轮询而不是事件:原生侧的窗口状态推送是发给**页面**的(shell-msg),宿主
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: { intervalMs?: number } = {},
191
+ opts: PlacementStoreOptions = {},
153
192
  ): PlacementStore {
154
- const intervalMs = opts.intervalMs ?? DEFAULTS.intervalMs;
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
- const tick = (): void => {
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
- // 写盘失败不该打断应用;下一次 tick 会重试
249
+ // 写盘失败不该打断应用;下一次变化会重试
189
250
  });
190
251
  };
191
252
 
192
- const timer = setInterval(tick, intervalMs);
193
- // 不阻止进程退出:应用退出时不该因为一个定时器挂住
194
- if (typeof timer === "object" && timer !== null && "unref" in timer) {
195
- (timer as { unref(): void }).unref();
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
- tick();
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
- clearInterval(timer);
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;