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/CHANGELOG.md +302 -63
- package/README.md +265 -146
- package/README.zh-CN.md +249 -147
- package/README.zh-TW.md +252 -146
- 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 +280 -279
- package/src/shell/ipc.ts +91 -33
- package/src/shell/native.ts +64 -62
- 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 +8 -19
- package/src/shell/types.ts +79 -81
- package/src/ui/titlebar.ts +1 -2
package/src/shell/ipc.ts
CHANGED
|
@@ -34,8 +34,7 @@ export type IpcRequestMessage = {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
export type IpcReplyMessage =
|
|
37
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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>>();
|
package/src/shell/native.ts
CHANGED
|
@@ -10,61 +10,66 @@ import type { Pointer } from "bun:ffi";
|
|
|
10
10
|
/**
|
|
11
11
|
* 打开 libshell.dll。
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
):
|
|
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
|
}
|