bun-app-window 0.6.2 → 0.8.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/src/shell/ipc.ts CHANGED
@@ -34,8 +34,7 @@ export type IpcRequestMessage = {
34
34
  };
35
35
 
36
36
  export type IpcReplyMessage =
37
- | { t: "ipc:res"; id: number; ok: true; r: unknown }
38
- | { t: "ipc:res"; id: number; ok: false; e: IpcErrorPayload };
37
+ { t: "ipc:res"; id: number; ok: true; r: unknown } | { t: "ipc:res"; id: number; ok: false; e: IpcErrorPayload };
39
38
 
40
39
  export type IpcEventMessage = {
41
40
  t: "ipc:evt";
@@ -65,17 +64,53 @@ export type IpcServerOptions = {
65
64
  service: ServiceMethods;
66
65
  /** 错误里是否回传 stack。开发期有用,生产应保持关闭 */
67
66
  exposeStack?: boolean;
67
+ /**
68
+ * 服务表出现客户端保留名时报错。默认 `true`。
69
+ *
70
+ * 保留名的表现是:服务端注册成功,客户端调用时解析为客户端自身成员,
71
+ * 请求不会发出,两端都不产生任何错误。默认终止启动的原因见 ADR 0020。
72
+ *
73
+ * 关闭此项仅应在服务表由外部输入动态生成、且已用别的手段校验时使用。
74
+ */
75
+ rejectReservedNames?: boolean;
76
+ };
77
+
78
+ /**
79
+ * IPC 客户端自身占用的属性名。
80
+ *
81
+ * 客户端是一个 Proxy,这些名字在 `get` 陷阱中解析为客户端成员,不会派发为远程调用。
82
+ * 服务表中同名的项因此永远不会被调用,而 `await client.close()` 也不会抛错
83
+ * (`await undefined` 是合法表达式),调用方无法区分「执行完毕」与「未派发」。
84
+ *
85
+ * 这份清单与客户端代理的拦截分支必须一致。`tests/ipc.test.ts` 中有一条断言逐名
86
+ * 验证「该名字不派发为远程调用」,两者分叉时该断言失败。
87
+ */
88
+ export const RESERVED_CLIENT_KEYS = ["on", "off", "close", "connected", "pending", "then"] as const;
89
+
90
+ /** 广播事件的选项 */
91
+ export type EmitOptions = {
92
+ /**
93
+ * 保留最后一次载荷,在连接建立时向该连接回放一次。
94
+ *
95
+ * 用途是状态同步。宿主通常在 `createApp` 返回后立即广播一次窗口状态,而此刻
96
+ * 页面仍在加载、WebSocket 尚未建立,广播投递给零个连接且不回放,页面据此
97
+ * 获取的初始状态将永远为空。粘性事件使新连接在 `attach` 时立即获得当前值。
98
+ *
99
+ * 默认关闭。逐条通知类的事件若开启粘性,新连接会额外收到一条历史消息,
100
+ * 与「只推变化」的预期不符。
101
+ */
102
+ sticky?: boolean;
68
103
  };
69
104
 
70
105
  export type IpcServer = {
71
106
  /** 处理一条来自连接的原始消息。非 IPC 消息会被静默忽略 */
72
107
  handle(peer: IpcPeer, raw: string | Buffer | Uint8Array): Promise<void>;
73
- /** 登记连接,`emit` 才会送到它 */
108
+ /** 登记连接,`emit` 才会送到它。粘性事件在此同步回放 */
74
109
  attach(peer: IpcPeer): void;
75
110
  /** 注销连接。close 时必须调,否则 emit 会向死连接写入 */
76
111
  detach(peer: IpcPeer): void;
77
112
  /** 广播事件给所有已登记连接。返回实际投递数 */
78
- emit(event: string, payload?: unknown): number;
113
+ emit(event: string, payload?: unknown, options?: EmitOptions): number;
79
114
  /** 服务暴露的方法名(创建时的快照)。用于自检与文档 */
80
115
  readonly methods: readonly string[];
81
116
  /** 当前登记连接数 */
@@ -87,12 +122,7 @@ function isRequest(v: unknown): v is IpcRequestMessage {
87
122
  return false;
88
123
  }
89
124
  const o = v as Record<string, unknown>;
90
- return (
91
- o.t === "ipc:req" &&
92
- typeof o.id === "number" &&
93
- typeof o.m === "string" &&
94
- Array.isArray(o.a)
95
- );
125
+ return o.t === "ipc:req" && typeof o.id === "number" && typeof o.m === "string" && Array.isArray(o.a);
96
126
  }
97
127
 
98
128
  /**
@@ -123,6 +153,28 @@ export function createIpcServer(opts: IpcServerOptions): IpcServer {
123
153
  // 创建时快照:之后往 service 上挂东西不会改变对外声明
124
154
  const methods = Object.keys(service);
125
155
 
156
+ // 保留名冲突在创建时终止,而不是留到首次调用。
157
+ // 该冲突的既有表现是「调用成功、返回 undefined、服务端未执行」,
158
+ // 既无异常也无日志,只能靠阅读客户端实现才能定位(ADR 0020)。
159
+ if (opts.rejectReservedNames !== false) {
160
+ const conflicts = methods.filter((m) => (RESERVED_CLIENT_KEYS as readonly string[]).includes(m));
161
+ if (conflicts.length > 0) {
162
+ throw new Error(
163
+ `IPC 服务表包含客户端保留名:${conflicts.join(", ")}。\n` +
164
+ `这些名字由 bun-app-window/ipc 的客户端自身占用,同名服务方法永远不会被调用,` +
165
+ `且调用方不会收到任何错误。请改用其他名字(例如 close 改为 winClose)。\n` +
166
+ `若服务表由外部输入生成且已另行校验,可传入 { rejectReservedNames: false } 关闭本检查。`,
167
+ );
168
+ }
169
+ }
170
+
171
+ /**
172
+ * 粘性事件的最后一次载荷。
173
+ *
174
+ * 每个事件名只保留一条,不累计:粘性的语义是「当前值」,不是「历史」。
175
+ */
176
+ const stickyEvents = new Map<string, IpcEventMessage>();
177
+
126
178
  const send = (peer: IpcPeer, msg: IpcMessage): void => {
127
179
  try {
128
180
  peer.send(JSON.stringify(msg));
@@ -162,9 +214,7 @@ export function createIpcServer(opts: IpcServerOptions): IpcServer {
162
214
  // hasOwnProperty 而非 `service[m]` 直接取:否则 "constructor" /
163
215
  // "__proto__" / "toString" 这类名字会命中原型链上的函数,
164
216
  // 等于把一个未知形状的东西当服务方法调用。
165
- const fn = Object.prototype.hasOwnProperty.call(service, parsed.m)
166
- ? service[parsed.m]
167
- : undefined;
217
+ const fn = Object.prototype.hasOwnProperty.call(service, parsed.m) ? service[parsed.m] : undefined;
168
218
 
169
219
  if (!fn) {
170
220
  send(peer, {
@@ -187,14 +237,27 @@ export function createIpcServer(opts: IpcServerOptions): IpcServer {
187
237
 
188
238
  attach(peer): void {
189
239
  connected.add(peer);
240
+ // 粘性事件在登记后同步回放:`attach` 返回时,新连接已收到全部当前值。
241
+ // 不引入定时器,避免「回放尚未到达」这一中间态。
242
+ for (const msg of stickyEvents.values()) {
243
+ try {
244
+ peer.send(JSON.stringify(msg));
245
+ } catch {
246
+ // 与 emit 一致:写入失败不中断其余回放
247
+ }
248
+ }
190
249
  },
191
250
 
192
251
  detach(peer): void {
193
252
  connected.delete(peer);
194
253
  },
195
254
 
196
- emit(event, payload): number {
255
+ emit(event, payload, options): number {
197
256
  const msg: IpcEventMessage = { t: "ipc:evt", e: event, p: payload ?? null };
257
+ if (options?.sticky === true) {
258
+ // 同名事件覆盖上一次:粘性表示「当前值」,不是历史队列
259
+ stickyEvents.set(event, msg);
260
+ }
198
261
  const body = JSON.stringify(msg);
199
262
  let delivered = 0;
200
263
  for (const peer of connected) {
@@ -235,24 +298,21 @@ export type Channel = {
235
298
 
236
299
  /** 把方法表映射成「返回 Promise 的同名方法」 */
237
300
  export type ClientMethods<T> = {
238
- [K in keyof T]: T[K] extends (...args: infer A) => infer R
239
- ? (...args: A) => Promise<Awaited<R>>
240
- : never;
301
+ [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (...args: A) => Promise<Awaited<R>> : never;
241
302
  };
242
303
 
243
- export type IpcClient<T, E extends Record<string, unknown> = Record<string, never>> =
244
- ClientMethods<T> & {
245
- /** 订阅宿主推送的事件。返回取消订阅函数 */
246
- on<K extends keyof E & string>(event: K, handler: (payload: E[K]) => void): () => void;
247
- /** 取消订阅 */
248
- off(event: string, handler: (payload: unknown) => void): void;
249
- /** 关闭通道。未完成的调用会被 reject */
250
- close(): void;
251
- /** 通道当前是否可用 */
252
- readonly connected: boolean;
253
- /** 尚未收到回复的调用数 */
254
- readonly pending: number;
255
- };
304
+ export type IpcClient<T, E extends Record<string, unknown> = Record<string, never>> = ClientMethods<T> & {
305
+ /** 订阅宿主推送的事件。返回取消订阅函数 */
306
+ on<K extends keyof E & string>(event: K, handler: (payload: E[K]) => void): () => void;
307
+ /** 取消订阅 */
308
+ off(event: string, handler: (payload: unknown) => void): void;
309
+ /** 关闭通道。未完成的调用会被 reject */
310
+ close(): void;
311
+ /** 通道当前是否可用 */
312
+ readonly connected: boolean;
313
+ /** 尚未收到回复的调用数 */
314
+ readonly pending: number;
315
+ };
256
316
 
257
317
  export type ConnectOptions = {
258
318
  /** 已建立的通道。给了就不再自己连 */
@@ -388,9 +448,7 @@ export async function connect<T, E extends Record<string, unknown> = Record<stri
388
448
  ): Promise<IpcClient<T, E>> {
389
449
  const timeoutMs = opts.timeoutMs ?? 30000;
390
450
 
391
- const channel =
392
- opts.channel ??
393
- (await webSocketChannel(opts.url ?? deriveUrl(opts.token)));
451
+ const channel = opts.channel ?? (await webSocketChannel(opts.url ?? deriveUrl(opts.token)));
394
452
 
395
453
  const pending = new Map<number, Pending>();
396
454
  const listeners = new Map<string, Set<(payload: unknown) => void>>();
@@ -10,61 +10,66 @@ import type { Pointer } from "bun:ffi";
10
10
  /**
11
11
  * 打开 libshell.dll。
12
12
  *
13
- * 导出面说明:DLL 实际导出 34 个符号,其中 `shell_start` 是 1.x 遗留入口,
14
- * TS 侧不声明、也不对外承诺(故下方声明 33 个);`shell_debug_*` / `shell_self_*` 属排障面。
13
+ * 导出面说明:`shell_start` 是 1.x 遗留入口,TS 侧不声明、也不对外承诺;
14
+ * `shell_debug_*` / `shell_self_*` 属排障面。声明总数由
15
+ * `tests/native-surface.test.ts` 的 `DECLARED` 钉住——本文不写数字,避免随增删漂移。
15
16
  */
16
17
  export function openNativeLib(path: string) {
17
- return dlopen(path, {
18
- shell_start_ex: {
19
- args: [FFIType.ptr, FFIType.ptr, FFIType.i32, FFIType.i32, FFIType.i32],
20
- returns: FFIType.i32,
21
- },
22
- shell_post: { args: [FFIType.ptr], returns: FFIType.i32 },
23
- shell_title: { args: [FFIType.ptr], returns: FFIType.i32 },
24
- shell_minimize: { args: [], returns: FFIType.i32 },
25
- shell_toggle_maximize: { args: [], returns: FFIType.i32 },
26
- shell_close: { args: [], returns: FFIType.i32 },
27
- shell_drag: { args: [], returns: FFIType.i32 },
28
- shell_begin_resize: { args: [FFIType.i32], returns: FFIType.i32 },
29
- shell_set_min_size: { args: [FFIType.i32, FFIType.i32], returns: FFIType.void },
30
- shell_get_state: { args: [], returns: FFIType.i32 },
31
- shell_is_alive: { args: [], returns: FFIType.i32 },
32
- // 窗口生命周期事件:批量拉取(消费式),capacity 是记录条数
33
- shell_poll_events: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
34
- shell_stop: { args: [], returns: FFIType.void },
35
- shell_error: { args: [], returns: FFIType.ptr },
36
- // 系统集成面
37
- shell_show: { args: [], returns: FFIType.i32 },
38
- shell_set_app_id: { args: [FFIType.ptr], returns: FFIType.i32 },
39
- shell_is_dark: { args: [], returns: FFIType.i32 },
40
- shell_pick_folder: { args: [FFIType.ptr, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
41
- shell_error_text: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
18
+ return dlopen(path, {
19
+ shell_start_ex: {
20
+ args: [FFIType.ptr, FFIType.ptr, FFIType.i32, FFIType.i32, FFIType.i32],
21
+ returns: FFIType.i32,
22
+ },
23
+ shell_post: { args: [FFIType.ptr], returns: FFIType.i32 },
24
+ shell_title: { args: [FFIType.ptr], returns: FFIType.i32 },
25
+ shell_minimize: { args: [], returns: FFIType.i32 },
26
+ shell_toggle_maximize: { args: [], returns: FFIType.i32 },
27
+ shell_close: { args: [], returns: FFIType.i32 },
28
+ shell_drag: { args: [], returns: FFIType.i32 },
29
+ shell_begin_resize: { args: [FFIType.i32], returns: FFIType.i32 },
30
+ shell_set_min_size: { args: [FFIType.i32, FFIType.i32], returns: FFIType.void },
31
+ shell_get_state: { args: [], returns: FFIType.i32 },
32
+ shell_is_alive: { args: [], returns: FFIType.i32 },
33
+ // 窗口生命周期事件:批量拉取(消费式),capacity 是记录条数
34
+ shell_poll_events: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
35
+ shell_stop: { args: [], returns: FFIType.void },
36
+ shell_error: { args: [], returns: FFIType.ptr },
37
+ // 系统集成面
38
+ shell_show: { args: [], returns: FFIType.i32 },
39
+ shell_set_app_id: { args: [FFIType.ptr], returns: FFIType.i32 },
40
+ shell_is_dark: { args: [], returns: FFIType.i32 },
41
+ shell_pick_folder: { args: [FFIType.ptr, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
42
+ shell_error_text: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
42
43
 
43
- // 应用化面(关掉浏览器行为 + 应用自定义右键菜单)
44
- shell_set_browser_ui: { args: [FFIType.i32], returns: FFIType.i32 },
45
- shell_show_app_menu: { args: [FFIType.i32, FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
44
+ // 应用化面(关掉浏览器行为 + 应用自定义右键菜单)
45
+ shell_set_browser_ui: { args: [FFIType.i32], returns: FFIType.i32 },
46
+ shell_show_app_menu: { args: [FFIType.i32, FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
46
47
 
47
- // 窗口位置面
48
- shell_get_bounds: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
49
- shell_restore_bounds: {
50
- args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32],
51
- returns: FFIType.i32,
52
- },
48
+ // 窗口位置面
49
+ shell_get_bounds: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
50
+ shell_restore_bounds: {
51
+ args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32],
52
+ returns: FFIType.i32,
53
+ },
53
54
 
54
- // 输入与视图面
55
- shell_set_zoom: { args: [FFIType.f64], returns: FFIType.i32 },
56
- shell_get_zoom: { args: [], returns: FFIType.f64 },
57
- shell_reload: { args: [], returns: FFIType.i32 },
58
- shell_devtools: { args: [], returns: FFIType.i32 },
59
- shell_toggle_fullscreen: { args: [], returns: FFIType.i32 },
55
+ // 输入与视图面
56
+ shell_set_zoom: { args: [FFIType.f64], returns: FFIType.i32 },
57
+ shell_get_zoom: { args: [], returns: FFIType.f64 },
58
+ shell_reload: { args: [], returns: FFIType.i32 },
59
+ shell_devtools: { args: [], returns: FFIType.i32 },
60
+ shell_toggle_fullscreen: { args: [], returns: FFIType.i32 },
61
+ // 运行时形态切换(ADR 0019)。只在创建期给了 SHELL_FLAG_FRAMELESS 时可用,
62
+ // 否则 set 返回 -1 并把理由写进 shell_error_text;is 恒回创建期形态。
63
+ shell_set_frameless: { args: [FFIType.i32], returns: FFIType.i32 },
64
+ shell_is_frameless: { args: [], returns: FFIType.i32 },
60
65
 
61
- // 排障面:不进稳定契约
62
- shell_debug_hittest: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
63
- shell_debug_metrics: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
64
- shell_debug_dpi: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
65
- shell_self_base: { args: [], returns: FFIType.u64 },
66
- shell_self_path: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
67
- });
66
+ // 排障面:不进稳定契约
67
+ shell_debug_hittest: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
68
+ shell_debug_metrics: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
69
+ shell_debug_dpi: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
70
+ shell_self_base: { args: [], returns: FFIType.u64 },
71
+ shell_self_path: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
72
+ });
68
73
  }
69
74
 
70
75
  export type NativeLib = ReturnType<typeof openNativeLib>;
@@ -75,20 +80,17 @@ export const cstr = (s: string) => ptr(new TextEncoder().encode(`${s}\0`));
75
80
 
76
81
  /** C 字符串指针 → JS 字符串;空指针或异常返回空串 */
77
82
  export function readCStr(p: unknown): string {
78
- if (!p) return "";
79
- try {
80
- return new CString(p as never).toString();
81
- } catch {
82
- return "";
83
- }
83
+ if (!p) return "";
84
+ try {
85
+ return new CString(p as never).toString();
86
+ } catch {
87
+ return "";
88
+ }
84
89
  }
85
90
 
86
91
  /** 读取原生侧写入定长缓冲的 UTF-8 文本 */
87
- export function readBuffer(
88
- fill: (p: Pointer, len: number) => number,
89
- size = 1024,
90
- ): string {
91
- const buf = new Uint8Array(size);
92
- const n = fill(ptr(buf), buf.length);
93
- return n > 0 ? new TextDecoder().decode(buf.subarray(0, n)) : "";
92
+ export function readBuffer(fill: (p: Pointer, len: number) => number, size = 1024): string {
93
+ const buf = new Uint8Array(size);
94
+ const n = fill(ptr(buf), buf.length);
95
+ return n > 0 ? new TextDecoder().decode(buf.subarray(0, n)) : "";
94
96
  }