bun-app-window 0.0.0-stage → 0.2.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 +122 -0
- package/LICENSE +21 -0
- package/README.md +432 -2
- package/README.zh-CN.md +431 -0
- package/README.zh-TW.md +431 -0
- package/dist/libshell.dll +0 -0
- package/package.json +65 -5
- package/src/shell/assets.d.ts +16 -0
- package/src/shell/debug.ts +40 -0
- package/src/shell/embedded.ts +42 -0
- package/src/shell/index.ts +291 -0
- package/src/shell/ipc.ts +569 -0
- package/src/shell/native-asset.d.ts +18 -0
- package/src/shell/native.ts +92 -0
- package/src/shell/port.ts +77 -0
- package/src/shell/resolve.ts +211 -0
- package/src/shell/state.ts +211 -0
- package/src/shell/types.ts +144 -0
- package/src/ui/titlebar.ts +436 -0
package/src/shell/ipc.ts
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ipc.ts — 宿主与页面之间的类型化调用与事件通道
|
|
3
|
+
*
|
|
4
|
+
* 三条设计决定:
|
|
5
|
+
*
|
|
6
|
+
* 1. **不生成代码。** MyGo 用 Go 写宿主,生成 TypeScript 客户端,是因为
|
|
7
|
+
* Go 类型与 TS 类型是两套系统,必须造一座桥。这里宿主与前端**都是
|
|
8
|
+
* TypeScript**,共享一份 `type` 声明就够了——生成器只会多出一个
|
|
9
|
+
* 「生成物与源不一致」的故障源,不带来任何类型信息。
|
|
10
|
+
* 类型安全由泛型 `connect<Api>()` 在编译期保证,和生成代码等价。
|
|
11
|
+
*
|
|
12
|
+
* 2. **传输无关。** 服务端只认「能 send 的对端」,客户端只认 `Channel`。
|
|
13
|
+
* 两侧核心逻辑都不碰 WebSocket,因此可以在无窗口环境下完整测试。
|
|
14
|
+
*
|
|
15
|
+
* 3. **不反射校验签名。** 运行时只按方法名分发,参数原样透传。
|
|
16
|
+
* `bun:ffi` 那条教训同样适用:声明对不对只能靠调用验证,
|
|
17
|
+
* 假装能校验反而会给出错误的信心。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** 失败时回给调用方的错误形状。`stack` 仅在 `exposeStack` 开启时出现 */
|
|
21
|
+
export type IpcErrorPayload = {
|
|
22
|
+
message: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
stack?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type IpcRequestMessage = {
|
|
28
|
+
t: "ipc:req";
|
|
29
|
+
id: number;
|
|
30
|
+
/** 方法名 */
|
|
31
|
+
m: string;
|
|
32
|
+
/** 参数数组。原样透传,不做序列化以外的处理 */
|
|
33
|
+
a: unknown[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type IpcReplyMessage =
|
|
37
|
+
| { t: "ipc:res"; id: number; ok: true; r: unknown }
|
|
38
|
+
| { t: "ipc:res"; id: number; ok: false; e: IpcErrorPayload };
|
|
39
|
+
|
|
40
|
+
export type IpcEventMessage = {
|
|
41
|
+
t: "ipc:evt";
|
|
42
|
+
/** 事件名 */
|
|
43
|
+
e: string;
|
|
44
|
+
/** 载荷。缺省为 null,保证字段始终存在 */
|
|
45
|
+
p: unknown;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type IpcMessage = IpcRequestMessage | IpcReplyMessage | IpcEventMessage;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 服务方法的宽松约束。
|
|
52
|
+
*
|
|
53
|
+
* 用 `never[]` 而非 `any[]`:任何具体函数 `(name: string) => string` 都能赋给
|
|
54
|
+
* `(...args: never[]) => unknown`(参数逆变,`never` 可赋给 `string`),
|
|
55
|
+
* 但反过来调用时需要断言——这正是我们想要的:定义宽松,调用收窄由泛型负责。
|
|
56
|
+
*/
|
|
57
|
+
export type ServiceMethods = Record<string, (...args: never[]) => unknown>;
|
|
58
|
+
|
|
59
|
+
/** 服务端对「一个连接」的最小要求。Bun 的 `ServerWebSocket` 天然满足 */
|
|
60
|
+
export type IpcPeer = {
|
|
61
|
+
send(data: string): void;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type IpcServerOptions = {
|
|
65
|
+
service: ServiceMethods;
|
|
66
|
+
/** 错误里是否回传 stack。开发期有用,生产应保持关闭 */
|
|
67
|
+
exposeStack?: boolean;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type IpcServer = {
|
|
71
|
+
/** 处理一条来自连接的原始消息。非 IPC 消息会被静默忽略 */
|
|
72
|
+
handle(peer: IpcPeer, raw: string | Buffer | Uint8Array): Promise<void>;
|
|
73
|
+
/** 登记连接,`emit` 才会送到它 */
|
|
74
|
+
attach(peer: IpcPeer): void;
|
|
75
|
+
/** 注销连接。close 时必须调,否则 emit 会向死连接写入 */
|
|
76
|
+
detach(peer: IpcPeer): void;
|
|
77
|
+
/** 广播事件给所有已登记连接。返回实际投递数 */
|
|
78
|
+
emit(event: string, payload?: unknown): number;
|
|
79
|
+
/** 服务暴露的方法名(创建时的快照)。用于自检与文档 */
|
|
80
|
+
readonly methods: readonly string[];
|
|
81
|
+
/** 当前登记连接数 */
|
|
82
|
+
readonly peers: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function isRequest(v: unknown): v is IpcRequestMessage {
|
|
86
|
+
if (!v || typeof v !== "object") {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
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
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 建立 IPC 服务端。
|
|
100
|
+
*
|
|
101
|
+
* ```ts
|
|
102
|
+
* const ipc = createIpcServer({
|
|
103
|
+
* service: {
|
|
104
|
+
* greet: (name: string) => `Hello ${name}`,
|
|
105
|
+
* pickFolder: () => shell.pickFolder(),
|
|
106
|
+
* },
|
|
107
|
+
* });
|
|
108
|
+
*
|
|
109
|
+
* Bun.serve({
|
|
110
|
+
* websocket: {
|
|
111
|
+
* open: (ws) => ipc.attach(ws),
|
|
112
|
+
* message: (ws, raw) => ipc.handle(ws, raw),
|
|
113
|
+
* close: (ws) => ipc.detach(ws),
|
|
114
|
+
* },
|
|
115
|
+
* // ...
|
|
116
|
+
* });
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export function createIpcServer(opts: IpcServerOptions): IpcServer {
|
|
120
|
+
const service = opts.service;
|
|
121
|
+
const exposeStack = opts.exposeStack === true;
|
|
122
|
+
const connected = new Set<IpcPeer>();
|
|
123
|
+
// 创建时快照:之后往 service 上挂东西不会改变对外声明
|
|
124
|
+
const methods = Object.keys(service);
|
|
125
|
+
|
|
126
|
+
const send = (peer: IpcPeer, msg: IpcMessage): void => {
|
|
127
|
+
try {
|
|
128
|
+
peer.send(JSON.stringify(msg));
|
|
129
|
+
} catch {
|
|
130
|
+
// 对端已断开。它会在 close 回调里被 detach,这里不必额外处理
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const toPayload = (err: unknown): IpcErrorPayload => {
|
|
135
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
136
|
+
const payload: IpcErrorPayload = { message: e.message };
|
|
137
|
+
if (e.name && e.name !== "Error") {
|
|
138
|
+
payload.name = e.name;
|
|
139
|
+
}
|
|
140
|
+
if (exposeStack && e.stack) {
|
|
141
|
+
payload.stack = e.stack;
|
|
142
|
+
}
|
|
143
|
+
return payload;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
async handle(peer, raw): Promise<void> {
|
|
148
|
+
const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
|
149
|
+
|
|
150
|
+
let parsed: unknown;
|
|
151
|
+
try {
|
|
152
|
+
parsed = JSON.parse(text);
|
|
153
|
+
} catch {
|
|
154
|
+
// 不是 JSON:这条通道只吃 JSON,别的消息归应用自己处理
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!isRequest(parsed)) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// hasOwnProperty 而非 `service[m]` 直接取:否则 "constructor" /
|
|
163
|
+
// "__proto__" / "toString" 这类名字会命中原型链上的函数,
|
|
164
|
+
// 等于把一个未知形状的东西当服务方法调用。
|
|
165
|
+
const fn = Object.prototype.hasOwnProperty.call(service, parsed.m)
|
|
166
|
+
? service[parsed.m]
|
|
167
|
+
: undefined;
|
|
168
|
+
|
|
169
|
+
if (!fn) {
|
|
170
|
+
send(peer, {
|
|
171
|
+
t: "ipc:res",
|
|
172
|
+
id: parsed.id,
|
|
173
|
+
ok: false,
|
|
174
|
+
e: { message: `unknown method: ${parsed.m}` },
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const result = await fn(...(parsed.a as never[]));
|
|
181
|
+
// JSON 里 undefined 会被省略成一个缺 r 字段的回复,显式归一成 null
|
|
182
|
+
send(peer, { t: "ipc:res", id: parsed.id, ok: true, r: result ?? null });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
send(peer, { t: "ipc:res", id: parsed.id, ok: false, e: toPayload(err) });
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
attach(peer): void {
|
|
189
|
+
connected.add(peer);
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
detach(peer): void {
|
|
193
|
+
connected.delete(peer);
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
emit(event, payload): number {
|
|
197
|
+
const msg: IpcEventMessage = { t: "ipc:evt", e: event, p: payload ?? null };
|
|
198
|
+
const body = JSON.stringify(msg);
|
|
199
|
+
let delivered = 0;
|
|
200
|
+
for (const peer of connected) {
|
|
201
|
+
try {
|
|
202
|
+
peer.send(body);
|
|
203
|
+
delivered++;
|
|
204
|
+
} catch {
|
|
205
|
+
// 死连接不计入
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return delivered;
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
get methods(): readonly string[] {
|
|
212
|
+
return methods;
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
get peers(): number {
|
|
216
|
+
return connected.size;
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 客户端的最小传输面。
|
|
223
|
+
*
|
|
224
|
+
* 只有四个动作——发一条、关掉、收消息、收关闭。测试里换成一个内存实现就能
|
|
225
|
+
* 完整覆盖客户端的 id 分配、pending 表、超时与错误传播,不需要起真服务。
|
|
226
|
+
*/
|
|
227
|
+
export type Channel = {
|
|
228
|
+
send(data: string): void;
|
|
229
|
+
close(): void;
|
|
230
|
+
/** 注册消息回调。只调一次;重复调用以最后一次为准 */
|
|
231
|
+
onMessage(fn: (data: string) => void): void;
|
|
232
|
+
/** 通道关闭(对端断开或本地 close)时触发 */
|
|
233
|
+
onClose(fn: () => void): void;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** 把方法表映射成「返回 Promise 的同名方法」 */
|
|
237
|
+
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;
|
|
241
|
+
};
|
|
242
|
+
|
|
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
|
+
};
|
|
256
|
+
|
|
257
|
+
export type ConnectOptions = {
|
|
258
|
+
/** 已建立的通道。给了就不再自己连 */
|
|
259
|
+
channel?: Channel;
|
|
260
|
+
/** 未给 channel 时的连接地址 */
|
|
261
|
+
url?: string;
|
|
262
|
+
/**
|
|
263
|
+
* 鉴权 token。缺省从页面 URL 的 `?t=` 读取——
|
|
264
|
+
* 与 `src/host.ts` 里本地服务的约定一致。
|
|
265
|
+
*/
|
|
266
|
+
token?: string;
|
|
267
|
+
/** 单次调用超时,毫秒。0 表示不超时。默认 30000 */
|
|
268
|
+
timeoutMs?: number;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
type NativeSocket = {
|
|
272
|
+
send(data: string): void;
|
|
273
|
+
close(): void;
|
|
274
|
+
readonly readyState: number;
|
|
275
|
+
addEventListener(type: string, listener: (event: unknown) => void): void;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 把一条 WebSocket 包成 `Channel`。
|
|
280
|
+
*
|
|
281
|
+
* open 之前到达的消息会先缓冲,`onMessage` 注册时补发——否则
|
|
282
|
+
* `await webSocketChannel()` 与 `client.onMessage()` 之间那条消息会丢。
|
|
283
|
+
*/
|
|
284
|
+
export function webSocketChannel(url: string): Promise<Channel> {
|
|
285
|
+
const Ctor = (globalThis as { WebSocket?: new (u: string) => NativeSocket }).WebSocket;
|
|
286
|
+
if (!Ctor) {
|
|
287
|
+
throw new Error("webSocketChannel: this runtime has no global WebSocket");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const socket = new Ctor(url);
|
|
291
|
+
const buffer: string[] = [];
|
|
292
|
+
let sink: ((data: string) => void) | null = null;
|
|
293
|
+
let onClosed: (() => void) | null = null;
|
|
294
|
+
|
|
295
|
+
socket.addEventListener("message", (ev) => {
|
|
296
|
+
const data = (ev as { data?: unknown }).data;
|
|
297
|
+
if (typeof data !== "string") {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (sink) {
|
|
301
|
+
sink(data);
|
|
302
|
+
} else {
|
|
303
|
+
buffer.push(data);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
socket.addEventListener("close", () => {
|
|
308
|
+
if (onClosed) {
|
|
309
|
+
onClosed();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return new Promise<Channel>((resolve, reject) => {
|
|
314
|
+
let settled = false;
|
|
315
|
+
|
|
316
|
+
socket.addEventListener("error", () => {
|
|
317
|
+
if (!settled) {
|
|
318
|
+
settled = true;
|
|
319
|
+
reject(new Error(`webSocketChannel: connection failed (${url})`));
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
socket.addEventListener("open", () => {
|
|
324
|
+
if (settled) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
settled = true;
|
|
328
|
+
resolve({
|
|
329
|
+
send: (data) => socket.send(data),
|
|
330
|
+
close: () => socket.close(),
|
|
331
|
+
onMessage: (fn) => {
|
|
332
|
+
sink = fn;
|
|
333
|
+
for (const pending of buffer.splice(0)) {
|
|
334
|
+
fn(pending);
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
onClose: (fn) => {
|
|
338
|
+
onClosed = fn;
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function toError(payload: unknown): Error {
|
|
346
|
+
if (payload && typeof payload === "object") {
|
|
347
|
+
const o = payload as Record<string, unknown>;
|
|
348
|
+
const message = typeof o.message === "string" ? o.message : "ipc call failed";
|
|
349
|
+
const err = new Error(message);
|
|
350
|
+
if (typeof o.name === "string" && o.name) {
|
|
351
|
+
err.name = o.name;
|
|
352
|
+
}
|
|
353
|
+
if (typeof o.stack === "string" && o.stack) {
|
|
354
|
+
err.stack = o.stack;
|
|
355
|
+
}
|
|
356
|
+
return err;
|
|
357
|
+
}
|
|
358
|
+
return new Error("ipc call failed");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
type Pending = {
|
|
362
|
+
resolve(value: unknown): void;
|
|
363
|
+
reject(err: Error): void;
|
|
364
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 连接宿主侧的 IPC 服务。
|
|
369
|
+
*
|
|
370
|
+
* ```ts
|
|
371
|
+
* // api.ts —— 宿主与页面共享这一份声明,不需要任何生成步骤
|
|
372
|
+
* export type Api = {
|
|
373
|
+
* greet(name: string): string;
|
|
374
|
+
* pickFolder(title?: string): Promise<string | null>;
|
|
375
|
+
* };
|
|
376
|
+
* export type Events = {
|
|
377
|
+
* windowState: { width: number; height: number; maximized: boolean };
|
|
378
|
+
* };
|
|
379
|
+
*
|
|
380
|
+
* // 页面
|
|
381
|
+
* const api = await connect<Api, Events>();
|
|
382
|
+
* const hello = await api.greet("Ada"); // string
|
|
383
|
+
* api.on("windowState", (s) => console.log(s.maximized));
|
|
384
|
+
* ```
|
|
385
|
+
*/
|
|
386
|
+
export async function connect<T, E extends Record<string, unknown> = Record<string, never>>(
|
|
387
|
+
opts: ConnectOptions = {},
|
|
388
|
+
): Promise<IpcClient<T, E>> {
|
|
389
|
+
const timeoutMs = opts.timeoutMs ?? 30000;
|
|
390
|
+
|
|
391
|
+
const channel =
|
|
392
|
+
opts.channel ??
|
|
393
|
+
(await webSocketChannel(opts.url ?? deriveUrl(opts.token)));
|
|
394
|
+
|
|
395
|
+
const pending = new Map<number, Pending>();
|
|
396
|
+
const listeners = new Map<string, Set<(payload: unknown) => void>>();
|
|
397
|
+
let nextId = 1;
|
|
398
|
+
let open = true;
|
|
399
|
+
|
|
400
|
+
const failAll = (message: string): void => {
|
|
401
|
+
for (const entry of pending.values()) {
|
|
402
|
+
if (entry.timer) {
|
|
403
|
+
clearTimeout(entry.timer);
|
|
404
|
+
}
|
|
405
|
+
entry.reject(new Error(message));
|
|
406
|
+
}
|
|
407
|
+
pending.clear();
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const call = (method: string, args: unknown[]): Promise<unknown> => {
|
|
411
|
+
if (!open) {
|
|
412
|
+
return Promise.reject(new Error(`ipc channel closed: ${method}`));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const id = nextId++;
|
|
416
|
+
nextId = nextId > Number.MAX_SAFE_INTEGER ? 1 : nextId;
|
|
417
|
+
|
|
418
|
+
const promise = new Promise<unknown>((resolve, reject) => {
|
|
419
|
+
const timer =
|
|
420
|
+
timeoutMs > 0
|
|
421
|
+
? setTimeout(() => {
|
|
422
|
+
// 超时后从表里摘掉,迟到的回复会被当成未知 id 丢弃
|
|
423
|
+
pending.delete(id);
|
|
424
|
+
reject(new Error(`ipc timeout after ${timeoutMs}ms: ${method}`));
|
|
425
|
+
}, timeoutMs)
|
|
426
|
+
: null;
|
|
427
|
+
pending.set(id, { resolve, reject, timer });
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
channel.send(JSON.stringify({ t: "ipc:req", id, m: method, a: args }));
|
|
432
|
+
} catch (err) {
|
|
433
|
+
const entry = pending.get(id);
|
|
434
|
+
pending.delete(id);
|
|
435
|
+
if (entry?.timer) {
|
|
436
|
+
clearTimeout(entry.timer);
|
|
437
|
+
}
|
|
438
|
+
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return promise;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
channel.onMessage((text) => {
|
|
445
|
+
let parsed: unknown;
|
|
446
|
+
try {
|
|
447
|
+
parsed = JSON.parse(text);
|
|
448
|
+
} catch {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (!parsed || typeof parsed !== "object") {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const o = parsed as Record<string, unknown>;
|
|
455
|
+
|
|
456
|
+
if (o.t === "ipc:res" && typeof o.id === "number") {
|
|
457
|
+
const entry = pending.get(o.id);
|
|
458
|
+
if (!entry) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
pending.delete(o.id);
|
|
462
|
+
if (entry.timer) {
|
|
463
|
+
clearTimeout(entry.timer);
|
|
464
|
+
}
|
|
465
|
+
if (o.ok === true) {
|
|
466
|
+
entry.resolve(o.r);
|
|
467
|
+
} else {
|
|
468
|
+
entry.reject(toError(o.e));
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (o.t === "ipc:evt" && typeof o.e === "string") {
|
|
474
|
+
const handlers = listeners.get(o.e);
|
|
475
|
+
if (!handlers) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
// 复制一份:处理器里调用 off() 不应打乱本次遍历
|
|
479
|
+
for (const handler of [...handlers]) {
|
|
480
|
+
try {
|
|
481
|
+
handler(o.p);
|
|
482
|
+
} catch {
|
|
483
|
+
// 单个处理器抛错不该打断整条事件通道
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
channel.onClose(() => {
|
|
490
|
+
open = false;
|
|
491
|
+
failAll("ipc channel closed");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const on = (event: string, handler: (payload: unknown) => void): (() => void) => {
|
|
495
|
+
let set = listeners.get(event);
|
|
496
|
+
if (!set) {
|
|
497
|
+
set = new Set();
|
|
498
|
+
listeners.set(event, set);
|
|
499
|
+
}
|
|
500
|
+
set.add(handler);
|
|
501
|
+
return () => {
|
|
502
|
+
set.delete(handler);
|
|
503
|
+
};
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const off = (event: string, handler: (payload: unknown) => void): void => {
|
|
507
|
+
listeners.get(event)?.delete(handler);
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const close = (): void => {
|
|
511
|
+
if (!open) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
open = false;
|
|
515
|
+
try {
|
|
516
|
+
channel.close();
|
|
517
|
+
} catch {
|
|
518
|
+
// 已经关掉的情况忽略
|
|
519
|
+
}
|
|
520
|
+
failAll("ipc client closed");
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// 用 Proxy 按方法名动态派发——这样客户端不需要知道任何方法清单,
|
|
524
|
+
// 方法集合完全由调用方传入的泛型 `T` 在编译期约束。
|
|
525
|
+
const proxy = new Proxy({} as Record<string | symbol, unknown>, {
|
|
526
|
+
get(_target, prop) {
|
|
527
|
+
if (prop === "on") {
|
|
528
|
+
return on;
|
|
529
|
+
}
|
|
530
|
+
if (prop === "off") {
|
|
531
|
+
return off;
|
|
532
|
+
}
|
|
533
|
+
if (prop === "close") {
|
|
534
|
+
return close;
|
|
535
|
+
}
|
|
536
|
+
if (prop === "connected") {
|
|
537
|
+
return open;
|
|
538
|
+
}
|
|
539
|
+
if (prop === "pending") {
|
|
540
|
+
return pending.size;
|
|
541
|
+
}
|
|
542
|
+
// `then` 必须返回 undefined:否则 `await client` 会把它当 thenable
|
|
543
|
+
// 无限等待下去。Symbol 同理(Symbol.toPrimitive 等)
|
|
544
|
+
if (prop === "then" || typeof prop === "symbol") {
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
return (...args: unknown[]) => call(prop, args);
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
return proxy as unknown as IpcClient<T, E>;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** 从页面 URL 推导 ws 地址。与服务端 `src/host.ts` 的约定一致 */
|
|
555
|
+
function deriveUrl(token?: string): string {
|
|
556
|
+
const loc = (
|
|
557
|
+
globalThis as {
|
|
558
|
+
location?: { search: string; protocol: string; host: string };
|
|
559
|
+
}
|
|
560
|
+
).location;
|
|
561
|
+
|
|
562
|
+
if (!loc) {
|
|
563
|
+
throw new Error("connect: no url given and no global location to derive one from");
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const t = token ?? new URLSearchParams(loc.search).get("t") ?? "";
|
|
567
|
+
const proto = loc.protocol === "https:" ? "wss:" : "ws:";
|
|
568
|
+
return `${proto}//${loc.host}/ws?t=${encodeURIComponent(t)}`;
|
|
569
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* native-asset.d.ts — `bun-app-window/native` 子路径的类型声明
|
|
3
|
+
*
|
|
4
|
+
* 该子路径的运行时目标是真实的 `dist/libshell.dll`,不是 JavaScript 模块,
|
|
5
|
+
* TypeScript 无从推导它的形状,消费方会得到 `TS2307: Cannot find module`。
|
|
6
|
+
* `package.json` 的 `exports["./native"]` 用 `types` 条件把类型指到本文件,
|
|
7
|
+
* 运行时的 `default` 条件仍指向 DLL 本体——两件事互不干扰。
|
|
8
|
+
*
|
|
9
|
+
* 为什么不做成 `.ts` 包装模块:那会让 `import.meta.resolve("bun-app-window/native")`
|
|
10
|
+
* 返回一个 `.ts` 路径。把 DLL 拷到 exe 旁边是真实用法,解析结果必须仍是 DLL 本身。
|
|
11
|
+
*
|
|
12
|
+
* 消费方用法(`with { type: "file" }` 不能省,否则打包器会试着把 DLL 当模块解析):
|
|
13
|
+
*
|
|
14
|
+
* import nativeLibPath from "bun-app-window/native" with { type: "file" };
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
declare const nativeLibPath: string;
|
|
18
|
+
export default nativeLibPath;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* native.ts — 原生库的 FFI 声明与字符串工具
|
|
3
|
+
*
|
|
4
|
+
* 只负责「怎么调」,不负责「库在哪」(后者见 resolve.ts)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CString, FFIType, dlopen, ptr } from "bun:ffi";
|
|
8
|
+
import type { Pointer } from "bun:ffi";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 打开 libshell.dll。
|
|
12
|
+
*
|
|
13
|
+
* 导出面说明:DLL 实际导出 33 个符号,其中 `shell_start` 是 1.x 遗留入口,
|
|
14
|
+
* TS 侧不声明、也不对外承诺(故下方声明 32 个);`shell_debug_*` / `shell_self_*` 属排障面。
|
|
15
|
+
*/
|
|
16
|
+
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
|
+
shell_stop: { args: [], returns: FFIType.void },
|
|
33
|
+
shell_error: { args: [], returns: FFIType.ptr },
|
|
34
|
+
// 系统集成面
|
|
35
|
+
shell_show: { args: [], returns: FFIType.i32 },
|
|
36
|
+
shell_set_app_id: { args: [FFIType.ptr], returns: FFIType.i32 },
|
|
37
|
+
shell_is_dark: { args: [], returns: FFIType.i32 },
|
|
38
|
+
shell_pick_folder: { args: [FFIType.ptr, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
39
|
+
shell_error_text: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
40
|
+
|
|
41
|
+
// 应用化面(关掉浏览器行为 + 应用自定义右键菜单)
|
|
42
|
+
shell_set_browser_ui: { args: [FFIType.i32], returns: FFIType.i32 },
|
|
43
|
+
shell_show_app_menu: { args: [FFIType.i32, FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
|
|
44
|
+
|
|
45
|
+
// 窗口位置面
|
|
46
|
+
shell_get_bounds: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
47
|
+
shell_restore_bounds: {
|
|
48
|
+
args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32],
|
|
49
|
+
returns: FFIType.i32,
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
// 输入与视图面
|
|
53
|
+
shell_set_zoom: { args: [FFIType.f64], returns: FFIType.i32 },
|
|
54
|
+
shell_get_zoom: { args: [], returns: FFIType.f64 },
|
|
55
|
+
shell_reload: { args: [], returns: FFIType.i32 },
|
|
56
|
+
shell_devtools: { args: [], returns: FFIType.i32 },
|
|
57
|
+
shell_toggle_fullscreen: { args: [], returns: FFIType.i32 },
|
|
58
|
+
|
|
59
|
+
// 排障面:不进稳定契约
|
|
60
|
+
shell_debug_hittest: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
61
|
+
shell_debug_metrics: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
62
|
+
shell_debug_dpi: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
63
|
+
shell_self_base: { args: [], returns: FFIType.u64 },
|
|
64
|
+
shell_self_path: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type NativeLib = ReturnType<typeof openNativeLib>;
|
|
69
|
+
export type NativeSymbols = NativeLib["symbols"];
|
|
70
|
+
|
|
71
|
+
/** JS 字符串 → 以 NUL 结尾的 UTF-8 C 字符串指针 */
|
|
72
|
+
export const cstr = (s: string) => ptr(new TextEncoder().encode(`${s}\0`));
|
|
73
|
+
|
|
74
|
+
/** C 字符串指针 → JS 字符串;空指针或异常返回空串 */
|
|
75
|
+
export function readCStr(p: unknown): string {
|
|
76
|
+
if (!p) return "";
|
|
77
|
+
try {
|
|
78
|
+
return new CString(p as never).toString();
|
|
79
|
+
} catch {
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 读取原生侧写入定长缓冲的 UTF-8 文本 */
|
|
85
|
+
export function readBuffer(
|
|
86
|
+
fill: (p: Pointer, len: number) => number,
|
|
87
|
+
size = 1024,
|
|
88
|
+
): string {
|
|
89
|
+
const buf = new Uint8Array(size);
|
|
90
|
+
const n = fill(ptr(buf), buf.length);
|
|
91
|
+
return n > 0 ? new TextDecoder().decode(buf.subarray(0, n)) : "";
|
|
92
|
+
}
|