bun-app-window 0.2.2 → 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.
@@ -0,0 +1,297 @@
1
+ /**
2
+ * events.ts — 窗口生命周期事件:解码、游标与拉取节奏
3
+ *
4
+ * ## 为什么是队列 + 拉取,而不是回调
5
+ *
6
+ * `webview_run` 会阻塞原生 UI 线程,而 `bun:ffi` 的 `JSCallback` 依赖空闲的 JS
7
+ * 主线程。二者叠加就是跨线程重入死锁(ADR 0002、`AGENTS.md` §5 第 5 条)。所以
8
+ * 原生层只能往一个定长环里写,宿主在自己的节奏里批量拉走——`shell_poll_events`。
9
+ *
10
+ * ## 单读者
11
+ *
12
+ * `shell_poll_events` 是**消费式**的:读走的记录不会再来。一个进程里只应有一个
13
+ * 消费者。要么用本模块的 `attachEventPump()`,要么自己按节奏调 `shell.pollEvents()`,
14
+ * 别两者都来——两个消费者会各自拿到一部分,谁也看不全。
15
+ *
16
+ * 需要多路关心同一批事件时(例如「位置持久化」与「宿主等关闭」),用一个 pump
17
+ * 加多个 `on()` 订阅,而不是起两个 pump。
18
+ *
19
+ * ## 序号与缺口
20
+ *
21
+ * 环满时丢最旧(原生侧裁断)。丢掉的记录不会伪装成还在,代价是 `seq` 出现断档——
22
+ * 这正是「丢了几条」的精确来源:`EventCursor` 把它换算成 `gaps`,比一条合成的
23
+ * 「溢出事件」信息量更大,也少一个要定义 `seq` 的特殊值。
24
+ *
25
+ * 新一次 `shell_start_ex` 会把 `seq` 归零。`seq` 回退即判定为重启(`restarted`),
26
+ * 缺口不跨重启点计算。
27
+ */
28
+
29
+ /**
30
+ * 一条事件记录的字节数。
31
+ *
32
+ * 与 `native/shell.cc` 的 `EventRecord` 严格一一对应(那边有
33
+ * `static_assert(sizeof(EventRecord) == 32)`)。改布局就是改 ABI。
34
+ */
35
+ export const EVENT_RECORD_BYTES = 32;
36
+
37
+ /** 窗口状态变化。载荷是抓取那一刻的快照,不是增量 */
38
+ export const EVENT_STATE = 1;
39
+ /** 窗口已销毁且原生单例已释放。终态:此后不再有任何事件 */
40
+ export const EVENT_CLOSED = 2;
41
+
42
+ /** 最大化(对应 `SHELL_EV_MAXIMIZED`) */
43
+ export const EV_MAXIMIZED = 1;
44
+ /** 最小化 */
45
+ export const EV_MINIMIZED = 2;
46
+ /** 前台焦点 */
47
+ export const EV_FOCUSED = 4;
48
+ /** 系统深色主题 */
49
+ export const EV_DARK = 8;
50
+
51
+ /** 状态快照:`EVENT_STATE` 的载荷 */
52
+ export type StateEvent = {
53
+ readonly type: typeof EVENT_STATE;
54
+ /** 单调递增,从 1 起;新一次启动归零 */
55
+ readonly seq: number;
56
+ /** 客户区宽(物理像素) */
57
+ readonly width: number;
58
+ /** 客户区高(物理像素) */
59
+ readonly height: number;
60
+ /** 窗口 DPI;页面 CSS 像素 = 物理像素 × 96 / dpi */
61
+ readonly dpi: number;
62
+ readonly maximized: boolean;
63
+ readonly minimized: boolean;
64
+ readonly focused: boolean;
65
+ readonly dark: boolean;
66
+ };
67
+
68
+ /** 终态:`EVENT_CLOSED` 的载荷 */
69
+ export type ClosedEvent = {
70
+ readonly type: typeof EVENT_CLOSED;
71
+ readonly seq: number;
72
+ };
73
+
74
+ export type WindowEvent = StateEvent | ClosedEvent;
75
+
76
+ /** 原生事件队列的容量。宿主一次拉取超过它没有意义 */
77
+ export const EVENT_QUEUE_CAPACITY = 128;
78
+
79
+ /**
80
+ * 把原生写出的定长缓冲解成事件数组。
81
+ *
82
+ * `count` 来自 `shell_poll_events` 的返回值。它按契约不会超过 `buf` 能容纳的条数,
83
+ * 但这里仍然夹一次——`count` 与 `buf` 由两处代码分别决定,夹一下的成本是一次除法,
84
+ * 换来的是「原生改了布局、TS 忘了同步」时不会越界读。
85
+ *
86
+ * 未知 `type` 直接跳过而不是抛错:旧宿主遇到将来新增的事件类型,应该继续工作。
87
+ */
88
+ export function decodeEvents(buf: Uint8Array, count: number): WindowEvent[] {
89
+ const room = Math.floor(buf.byteLength / EVENT_RECORD_BYTES);
90
+ const n = Math.min(Math.max(0, Math.trunc(count)), room);
91
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
92
+ const out: WindowEvent[] = [];
93
+
94
+ for (let i = 0; i < n; i++) {
95
+ const at = i * EVENT_RECORD_BYTES;
96
+ const seq = view.getUint32(at, true);
97
+ const type = view.getUint32(at + 4, true);
98
+
99
+ if (type === EVENT_CLOSED) {
100
+ out.push({ type: EVENT_CLOSED, seq });
101
+ continue;
102
+ }
103
+ if (type !== EVENT_STATE) {
104
+ continue;
105
+ }
106
+
107
+ const flags = view.getUint32(at + 24, true);
108
+ out.push({
109
+ type: EVENT_STATE,
110
+ seq,
111
+ width: view.getInt32(at + 8, true),
112
+ height: view.getInt32(at + 12, true),
113
+ dpi: view.getInt32(at + 16, true),
114
+ maximized: (flags & EV_MAXIMIZED) !== 0,
115
+ minimized: (flags & EV_MINIMIZED) !== 0,
116
+ focused: (flags & EV_FOCUSED) !== 0,
117
+ dark: (flags & EV_DARK) !== 0,
118
+ });
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** 只要求「能拉一批事件」,不绑定 `Shell`——便于测试与自定义源 */
124
+ export type EventSource = {
125
+ pollEvents(max?: number): WindowEvent[];
126
+ };
127
+
128
+ export type EventBatch = {
129
+ readonly events: readonly WindowEvent[];
130
+ /** 每项是「这里丢了几条」。空数组表示序号连续 */
131
+ readonly gaps: readonly number[];
132
+ /** `seq` 回退,说明原生侧重启过 */
133
+ readonly restarted: boolean;
134
+ };
135
+
136
+ /**
137
+ * 事件游标:把「序号」翻译成「有没有丢」。
138
+ *
139
+ * 抽成独立对象是因为这是本模块唯一有状态、且状态正确性不能靠端到端验证的部分——
140
+ * 真窗口能证明「拉到事件了」,证明不了「丢了事件时能看出来」。
141
+ */
142
+ export class EventCursor {
143
+ private last = 0;
144
+
145
+ /** 最近一次收到的 `seq`;0 表示还没收到过 */
146
+ get seq(): number {
147
+ return this.last;
148
+ }
149
+
150
+ /** 丢弃已记录的位置(换了一个窗口重新开始看时用) */
151
+ reset(): void {
152
+ this.last = 0;
153
+ }
154
+
155
+ push(events: readonly WindowEvent[]): EventBatch {
156
+ const gaps: number[] = [];
157
+ let restarted = false;
158
+
159
+ for (const e of events) {
160
+ // 序号回退 = 原生侧重启(`reset_events()` 把序号归零)。
161
+ // 重启后的第一条不检查缺口:新的一轮从 1 开始,把它当成「前面丢了 N 条」
162
+ // 会把重启本身误报成丢事件。
163
+ let afterRestart = false;
164
+ if (this.last > 0 && e.seq <= this.last) {
165
+ restarted = true;
166
+ this.last = 0;
167
+ afterRestart = true;
168
+ }
169
+ // last 为 0 且不是重启后的第一条(宿主从未读过)时,首条不是 1 同样说明有丢弃
170
+ if (!afterRestart && e.seq > this.last + 1) {
171
+ gaps.push(e.seq - this.last - 1);
172
+ }
173
+ this.last = e.seq;
174
+ }
175
+
176
+ return { events, gaps, restarted };
177
+ }
178
+ }
179
+
180
+ export type EventPump = {
181
+ /** 订阅。返回退订函数 */
182
+ on(fn: (e: WindowEvent) => void): () => void;
183
+ /** 订阅缺口。丢了多久之后不会再补,只会告诉你有多少条没看见 */
184
+ onGap(fn: (missing: number) => void): () => void;
185
+ stop(): void;
186
+ readonly running: boolean;
187
+ /** 已消费到的最新序号 */
188
+ readonly seq: number;
189
+ };
190
+
191
+ export type EventPumpOptions = {
192
+ /** 拉取节奏。默认 16 ms,约一帧 */
193
+ intervalMs?: number;
194
+ /** 一次最多拉多少条。默认 64,不超过 `EVENT_QUEUE_CAPACITY` */
195
+ maxBatch?: number;
196
+ /**
197
+ * 是否让定时器阻止进程退出。默认 `false`(unref)。
198
+ *
199
+ * 设 `true` 的场景只有一种:**靠这个 pump 等窗口关闭**。unref 的定时器不
200
+ * 阻止退出,而一个永不兑现的 promise 也撑不住事件循环——宿主会在窗口还开着
201
+ * 的时候直接退出。`./app` 的骨架因此传 `true`。
202
+ */
203
+ keepAlive?: boolean;
204
+ };
205
+
206
+ /**
207
+ * 按固定节奏从原生侧拉取事件,并分发给订阅者。
208
+ *
209
+ * 收到 `EVENT_CLOSED` 后自动 `stop()`:那是终态,之后再拉只有空数组,留着定时器
210
+ * 只是白烧 CPU。
211
+ *
212
+ * 每个窗口只应有一个 pump(见模块头「单读者」)。
213
+ */
214
+ export function attachEventPump(src: EventSource, opts: EventPumpOptions = {}): EventPump {
215
+ const intervalMs = opts.intervalMs ?? 16;
216
+ // 原生环就 EVENT_QUEUE_CAPACITY 条,要更多没有意义:多出来的缓冲永远不会被写满
217
+ const maxBatch = Math.min(Math.max(1, opts.maxBatch ?? 64), EVENT_QUEUE_CAPACITY);
218
+ const keepAlive = opts.keepAlive ?? false;
219
+
220
+ const cursor = new EventCursor();
221
+ const listeners = new Set<(e: WindowEvent) => void>();
222
+ const gapListeners = new Set<(missing: number) => void>();
223
+ let stopped = false;
224
+
225
+ function stop(): void {
226
+ if (stopped) {
227
+ return;
228
+ }
229
+ stopped = true;
230
+ clearInterval(timer);
231
+ }
232
+
233
+ const tick = (): void => {
234
+ if (stopped) {
235
+ return;
236
+ }
237
+ let batch: EventBatch;
238
+ try {
239
+ batch = cursor.push(src.pollEvents(maxBatch));
240
+ } catch {
241
+ // 原生库已被卸载、窗口正在销毁:这一拍跳过,下一拍再说。
242
+ // 定时器回调里抛出的异常会直接终止进程,而这里没有任何值得终止的事。
243
+ return;
244
+ }
245
+
246
+ if (batch.gaps.length > 0) {
247
+ for (const missing of batch.gaps) {
248
+ for (const fn of gapListeners) fn(missing);
249
+ }
250
+ }
251
+
252
+ for (const e of batch.events) {
253
+ for (const fn of listeners) fn(e);
254
+ if (e.type === EVENT_CLOSED) {
255
+ stop();
256
+ return;
257
+ }
258
+ }
259
+ };
260
+
261
+ const timer = setInterval(tick, intervalMs);
262
+ if (!keepAlive) unrefTimer(timer);
263
+
264
+ return {
265
+ on(fn) {
266
+ listeners.add(fn);
267
+ return () => {
268
+ listeners.delete(fn);
269
+ };
270
+ },
271
+ onGap(fn) {
272
+ gapListeners.add(fn);
273
+ return () => {
274
+ gapListeners.delete(fn);
275
+ };
276
+ },
277
+ stop,
278
+ get running() {
279
+ return !stopped;
280
+ },
281
+ get seq() {
282
+ return cursor.seq;
283
+ },
284
+ };
285
+ }
286
+
287
+ /**
288
+ * 让定时器不阻止进程退出。
289
+ *
290
+ * Bun 与 Node 的定时器对象都有 `unref()`,但类型库里 `setInterval` 的返回值在
291
+ * DOM 与运行时两种声明下不同,所以按结构判断而不是按类型断言。
292
+ */
293
+ export function unrefTimer(timer: unknown): void {
294
+ if (typeof timer === "object" && timer !== null && "unref" in timer) {
295
+ (timer as { unref(): void }).unref();
296
+ }
297
+ }
@@ -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;
@@ -42,6 +42,24 @@ export const FLAG_ACCEPT_DROP = 32;
42
42
  * 与 `FLAG_CENTER` 同时给时以本项为准(最大化会覆盖位置)。
43
43
  */
44
44
  export const FLAG_START_MAXIMIZED = 64;
45
+ /**
46
+ * 允许页面把顶层框架导航到任意 http/https 地址,即**放弃外部导航拦截**。
47
+ *
48
+ * 默认(不给本位)时,壳会拦下跨源导航并改交系统默认浏览器:桌面应用没有地址栏、
49
+ * 没有后退按钮,也没有宿主 API 能把页面拉回来,一次误点外链就等于应用被换成了那个
50
+ * 网站。这是默认值应该选安全一侧的典型场景。
51
+ *
52
+ * 什么时候需要本位:应用本身就是浏览器(多标签、内置地址栏、能自己导航回来)。
53
+ *
54
+ * 边界(两条,别指望它能关掉更多):
55
+ *
56
+ * - **只影响顶层框架导航**。`window.open` / `target=_blank` 仍然被拦下并外开,
57
+ * 因为壳只支持一个顶层窗口(原生层是进程级单例),开出来的新窗口没有任何宿主
58
+ * API 能管它。这是能力边界,不是安全策略。
59
+ * - **不放宽 scheme 白名单**。交给系统浏览器的只有 http/https;`file:`、
60
+ * `javascript:`、`ms-*:` 以及自定义 scheme 一律静默取消,与本位无关。
61
+ */
62
+ export const FLAG_ALLOW_EXTERNAL_NAV = 128;
45
63
 
46
64
  /**
47
65
  * 关闭 WebView2 的浏览器行为(`ShellOptions.browserUi` 使用)。