@morlay/dsh-desktop-host 0.2.5 → 0.2.6
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/README.md +20 -6
- package/config/desktop.cordis.patch.yml +37 -35
- package/dist/index.d.mts +1 -0
- package/dist/index.js +430 -0
- package/dist/paths.d.mts +4 -0
- package/dist/paths.js +5 -0
- package/dist/preload-source.d.mts +29 -0
- package/dist/preload-source.js +80 -0
- package/dist/settings-namespace.d.mts +9 -0
- package/dist/settings-namespace.js +13 -0
- package/dist/wire.d.mts +109 -0
- package/{lib → dist}/wire.js +4 -1
- package/package.json +39 -16
- package/src/__tests__/desktop-patch.spec.ts +171 -0
- package/src/__tests__/inspector.spec.ts +50 -0
- package/src/__tests__/invocation.spec.ts +60 -0
- package/src/__tests__/manifest.spec.ts +70 -0
- package/src/__tests__/web-entry.spec.ts +13 -0
- package/src/__tests__/wire.spec.ts +238 -0
- package/src/desktop-ipc.ts +71 -0
- package/src/fatal.ts +30 -0
- package/src/index.ts +126 -0
- package/src/inspector.ts +44 -0
- package/src/invocation.ts +68 -0
- package/src/paths.ts +4 -0
- package/src/preload-source.ts +86 -0
- package/src/settings-namespace.ts +31 -0
- package/src/transport.ts +286 -0
- package/src/web.ts +57 -0
- package/src/wire.ts +480 -0
- package/lib/index.js +0 -390
- package/lib/webserver.js +0 -250
package/src/wire.ts
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// 桌面 host 与 Electron 壳之间的字节管道协议:请求帧(壳 → host,FD 3)与响应帧(host → 壳,FD 4)。
|
|
2
|
+
// 帧头 13 字节:magic(4) | type(1) | streamId(4) | payloadLength(4);data 帧是原始字节,其余是 JSON
|
|
3
|
+
// 或空载荷。
|
|
4
|
+
import { StringDecoder } from "node:string_decoder";
|
|
5
|
+
|
|
6
|
+
export const DESKTOP_HOST_PROTOCOL_VERSION = 1 as const;
|
|
7
|
+
|
|
8
|
+
// 桌面流的宿主路径:页面/主进程都用它(两侧共享一份,避免路径写两处)。
|
|
9
|
+
export const DESKTOP_STREAM_PATH = "/.dsh/remote-stream";
|
|
10
|
+
|
|
11
|
+
// 桌面流请求体的首行:定下这条逻辑流的 endpoint 与 payload,后续行都是上行项
|
|
12
|
+
// (客户端 → 宿主;`$events` 这类宿主自己的流被上游释放,不会读到它们)。
|
|
13
|
+
export function encodeDesktopStreamOpen(endpoint: string, payload: unknown): string {
|
|
14
|
+
return `${JSON.stringify({ endpoint, payload })}\n`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 桌面流请求体里的一道上行项。
|
|
18
|
+
export function encodeDesktopStreamItem(item: unknown): string {
|
|
19
|
+
return `${JSON.stringify(item)}\n`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 壳写请求帧的管道描述符。
|
|
23
|
+
export const DESKTOP_REQUEST_PIPE_FD = 3;
|
|
24
|
+
|
|
25
|
+
// host 写响应帧的管道描述符。
|
|
26
|
+
export const DESKTOP_RESPONSE_PIPE_FD = 4;
|
|
27
|
+
|
|
28
|
+
// Node 生命周期 IPC 通道的描述符(ready / fatal / shutdown)。
|
|
29
|
+
export const DESKTOP_CONTROL_IPC_FD = 5;
|
|
30
|
+
|
|
31
|
+
// 单个 data 帧携带的最大原始字节数。
|
|
32
|
+
export const DESKTOP_PIPE_CHUNK_BYTES = 64 * 1024;
|
|
33
|
+
|
|
34
|
+
const FRAME_MAGIC = 0x44534833;
|
|
35
|
+
const FRAME_HEADER_BYTES = 13;
|
|
36
|
+
const MAX_CONTROL_PAYLOAD_BYTES = 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
const REQUEST_FRAME_START = 1;
|
|
39
|
+
const REQUEST_FRAME_DATA = 2;
|
|
40
|
+
const REQUEST_FRAME_END = 3;
|
|
41
|
+
const REQUEST_FRAME_CANCEL = 4;
|
|
42
|
+
|
|
43
|
+
const RESPONSE_FRAME_START = 1;
|
|
44
|
+
const RESPONSE_FRAME_DATA = 2;
|
|
45
|
+
const RESPONSE_FRAME_END = 3;
|
|
46
|
+
const RESPONSE_FRAME_ERROR = 4;
|
|
47
|
+
|
|
48
|
+
type FrameType = number;
|
|
49
|
+
|
|
50
|
+
// 一个请求流开头的元数据。
|
|
51
|
+
export interface DesktopHostRequestStart {
|
|
52
|
+
readonly url: string;
|
|
53
|
+
readonly method: string;
|
|
54
|
+
readonly headers: readonly [string, string][];
|
|
55
|
+
readonly hasBody: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 一条校验过的请求帧。
|
|
59
|
+
export type DesktopHostRequestFrame =
|
|
60
|
+
| ({ readonly type: "start"; readonly streamId: number } & DesktopHostRequestStart)
|
|
61
|
+
| { readonly type: "data"; readonly streamId: number; readonly data: Buffer }
|
|
62
|
+
| { readonly type: "end"; readonly streamId: number }
|
|
63
|
+
| { readonly type: "cancel"; readonly streamId: number };
|
|
64
|
+
|
|
65
|
+
// 一条校验过的响应帧。
|
|
66
|
+
export type DesktopHostResponseFrame =
|
|
67
|
+
| {
|
|
68
|
+
readonly type: "start";
|
|
69
|
+
readonly streamId: number;
|
|
70
|
+
readonly status: number;
|
|
71
|
+
readonly headers: readonly [string, string][];
|
|
72
|
+
readonly hasBody: boolean;
|
|
73
|
+
}
|
|
74
|
+
| { readonly type: "data"; readonly streamId: number; readonly data: Buffer }
|
|
75
|
+
| { readonly type: "end"; readonly streamId: number }
|
|
76
|
+
| { readonly type: "error"; readonly streamId: number; readonly message: string };
|
|
77
|
+
|
|
78
|
+
// 仍留在 Node IPC 上的控制命令(不携带 Fetch 载荷字节)。
|
|
79
|
+
export type DesktopHostCommand =
|
|
80
|
+
| { readonly type: "shutdown" }
|
|
81
|
+
// 打开回环上的 Node inspector(`port` 为 0 表示随机端口)。
|
|
82
|
+
| { readonly type: "inspect"; readonly port: number }
|
|
83
|
+
// 关掉它;重复关是幂等的。
|
|
84
|
+
| { readonly type: "inspect-off" };
|
|
85
|
+
|
|
86
|
+
// 仍留在 Node IPC 上的生命周期事件。
|
|
87
|
+
export type DesktopHostEvent =
|
|
88
|
+
| {
|
|
89
|
+
readonly type: "ready";
|
|
90
|
+
readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION;
|
|
91
|
+
/**
|
|
92
|
+
* webServer 的 origin(`http://127.0.0.1:<port>`):壳用它载入窗口。基于 URL/WS 的东西(settings 的
|
|
93
|
+
* describe 流、账号的 stream mux、vite 的 HMR)要求窗口来源就是它。
|
|
94
|
+
*/
|
|
95
|
+
readonly origin: string;
|
|
96
|
+
}
|
|
97
|
+
| {
|
|
98
|
+
readonly type: "fatal";
|
|
99
|
+
// 给人看的一句失败原因(壳的 dialog 用它)。
|
|
100
|
+
readonly message: string;
|
|
101
|
+
// 完整诊断(`util.inspect` 的错误,含 code/syscall/path/cause);缺省时壳只有 message。
|
|
102
|
+
readonly diagnostic?: string;
|
|
103
|
+
}
|
|
104
|
+
| {
|
|
105
|
+
readonly type: "inspected";
|
|
106
|
+
// 当前可用的调试端点;为 null 表示没开(被关掉或开不起来,原因在 `message`)。
|
|
107
|
+
readonly url: string | null;
|
|
108
|
+
readonly message?: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
112
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isHeaders(value: unknown): value is readonly [string, string][] {
|
|
116
|
+
return (
|
|
117
|
+
Array.isArray(value) &&
|
|
118
|
+
value.every(
|
|
119
|
+
(header) =>
|
|
120
|
+
Array.isArray(header) &&
|
|
121
|
+
header.length === 2 &&
|
|
122
|
+
typeof header[0] === "string" &&
|
|
123
|
+
typeof header[1] === "string",
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertStreamId(streamId: number): void {
|
|
129
|
+
if (!Number.isInteger(streamId) || streamId < 1 || streamId > 0xffff_ffff)
|
|
130
|
+
throw new Error(`dsh desktop: invalid pipe stream id ${String(streamId)}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function encodeFrame(
|
|
134
|
+
type: FrameType,
|
|
135
|
+
streamId: number,
|
|
136
|
+
payload: Buffer,
|
|
137
|
+
dataType: FrameType,
|
|
138
|
+
direction: string,
|
|
139
|
+
): Buffer {
|
|
140
|
+
assertStreamId(streamId);
|
|
141
|
+
const limit = type === dataType ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES;
|
|
142
|
+
if (payload.byteLength > limit)
|
|
143
|
+
throw new Error(`dsh desktop: ${direction} pipe frame exceeds the ${String(limit)}-byte limit`);
|
|
144
|
+
const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + payload.byteLength);
|
|
145
|
+
frame.writeUInt32BE(FRAME_MAGIC, 0);
|
|
146
|
+
frame.writeUInt8(type, 4);
|
|
147
|
+
frame.writeUInt32BE(streamId, 5);
|
|
148
|
+
frame.writeUInt32BE(payload.byteLength, 9);
|
|
149
|
+
payload.copy(frame, FRAME_HEADER_BYTES);
|
|
150
|
+
return frame;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function jsonPayload(
|
|
154
|
+
type: FrameType,
|
|
155
|
+
streamId: number,
|
|
156
|
+
value: unknown,
|
|
157
|
+
dataType: FrameType,
|
|
158
|
+
direction: string,
|
|
159
|
+
): Buffer {
|
|
160
|
+
return encodeFrame(
|
|
161
|
+
type,
|
|
162
|
+
streamId,
|
|
163
|
+
Buffer.from(JSON.stringify(value), "utf8"),
|
|
164
|
+
dataType,
|
|
165
|
+
direction,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 编码开启一个请求流的元数据帧(壳侧)。
|
|
170
|
+
export function encodeDesktopRequestStart(
|
|
171
|
+
streamId: number,
|
|
172
|
+
request: DesktopHostRequestStart,
|
|
173
|
+
): Buffer {
|
|
174
|
+
return jsonPayload(REQUEST_FRAME_START, streamId, request, REQUEST_FRAME_DATA, "request");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 编码一个受限的请求体原始分片(壳侧)。
|
|
178
|
+
export function encodeDesktopRequestData(streamId: number, data: Uint8Array): Buffer {
|
|
179
|
+
return encodeFrame(
|
|
180
|
+
REQUEST_FRAME_DATA,
|
|
181
|
+
streamId,
|
|
182
|
+
Buffer.from(data),
|
|
183
|
+
REQUEST_FRAME_DATA,
|
|
184
|
+
"request",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 编码请求体正常结束(壳侧)。
|
|
189
|
+
export function encodeDesktopRequestEnd(streamId: number): Buffer {
|
|
190
|
+
return encodeFrame(REQUEST_FRAME_END, streamId, Buffer.alloc(0), REQUEST_FRAME_DATA, "request");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 编码取消一个请求及其响应(壳侧)。
|
|
194
|
+
export function encodeDesktopRequestCancel(streamId: number): Buffer {
|
|
195
|
+
return encodeFrame(
|
|
196
|
+
REQUEST_FRAME_CANCEL,
|
|
197
|
+
streamId,
|
|
198
|
+
Buffer.alloc(0),
|
|
199
|
+
REQUEST_FRAME_DATA,
|
|
200
|
+
"request",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 编码响应元数据帧(host 侧)。
|
|
205
|
+
export function encodeDesktopResponseStart(
|
|
206
|
+
streamId: number,
|
|
207
|
+
response: {
|
|
208
|
+
readonly status: number;
|
|
209
|
+
readonly headers: readonly [string, string][];
|
|
210
|
+
readonly hasBody: boolean;
|
|
211
|
+
},
|
|
212
|
+
): Buffer {
|
|
213
|
+
return jsonPayload(RESPONSE_FRAME_START, streamId, response, RESPONSE_FRAME_DATA, "response");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 编码一个受限的响应体原始分片(host 侧)。
|
|
217
|
+
export function encodeDesktopResponseData(streamId: number, data: Uint8Array): Buffer {
|
|
218
|
+
return encodeFrame(
|
|
219
|
+
RESPONSE_FRAME_DATA,
|
|
220
|
+
streamId,
|
|
221
|
+
Buffer.from(data),
|
|
222
|
+
RESPONSE_FRAME_DATA,
|
|
223
|
+
"response",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 编码响应正常结束(host 侧)。
|
|
228
|
+
export function encodeDesktopResponseEnd(streamId: number): Buffer {
|
|
229
|
+
return encodeFrame(
|
|
230
|
+
RESPONSE_FRAME_END,
|
|
231
|
+
streamId,
|
|
232
|
+
Buffer.alloc(0),
|
|
233
|
+
RESPONSE_FRAME_DATA,
|
|
234
|
+
"response",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 编码一次响应失败,不把 Error 对象跨进程传递(host 侧)。
|
|
239
|
+
export function encodeDesktopResponseError(streamId: number, message: string): Buffer {
|
|
240
|
+
return jsonPayload(RESPONSE_FRAME_ERROR, streamId, { message }, RESPONSE_FRAME_DATA, "response");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function frameLength(buffer: Buffer, dataType: FrameType, direction: string): number | undefined {
|
|
244
|
+
if (buffer.byteLength < FRAME_HEADER_BYTES) return undefined;
|
|
245
|
+
if (buffer.readUInt32BE(0) !== FRAME_MAGIC)
|
|
246
|
+
throw new Error(`dsh desktop: invalid ${direction} frame marker`);
|
|
247
|
+
const type = buffer.readUInt8(4);
|
|
248
|
+
const streamId = buffer.readUInt32BE(5);
|
|
249
|
+
const payloadLength = buffer.readUInt32BE(9);
|
|
250
|
+
assertStreamId(streamId);
|
|
251
|
+
const limit = type === dataType ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES;
|
|
252
|
+
if (payloadLength > limit)
|
|
253
|
+
throw new Error(`dsh desktop: ${direction} frame exceeds the ${String(limit)}-byte limit`);
|
|
254
|
+
const total = FRAME_HEADER_BYTES + payloadLength;
|
|
255
|
+
return buffer.byteLength < total ? undefined : total;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseJson(payload: Buffer, subject: string): unknown {
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(payload.toString("utf8")) as unknown;
|
|
261
|
+
} catch (error) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`dsh desktop: ${subject} payload is not JSON: ` +
|
|
264
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function assertEmpty(payload: Buffer, subject: string): void {
|
|
270
|
+
if (payload.byteLength !== 0) throw new Error(`dsh desktop: ${subject} frame carried a payload`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 增量解码桌面流请求体:按行取 JSON,首行是 `open`(endpoint / payload),后续每行是一道上行项。
|
|
274
|
+
// 字节边界可能切开多字节字符,所以先过 `StringDecoder` 再切行。
|
|
275
|
+
export class DesktopStreamBodyDecoder {
|
|
276
|
+
private readonly text = new StringDecoder("utf8");
|
|
277
|
+
private buffer = "";
|
|
278
|
+
|
|
279
|
+
// 追加字节并返回所有已完整的行的解析值(空行忽略)。
|
|
280
|
+
push(chunk: Uint8Array): unknown[] {
|
|
281
|
+
this.buffer += this.text.write(Buffer.from(chunk));
|
|
282
|
+
return this.take(false);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 请求体结束:交付末行(没有换行收尾时也存在)。
|
|
286
|
+
finish(): unknown[] {
|
|
287
|
+
this.buffer += this.text.end();
|
|
288
|
+
return this.take(true);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private take(last: boolean): unknown[] {
|
|
292
|
+
const values: unknown[] = [];
|
|
293
|
+
for (;;) {
|
|
294
|
+
const at = this.buffer.indexOf("\n");
|
|
295
|
+
if (at === -1) {
|
|
296
|
+
if (!last) return values;
|
|
297
|
+
const tail = this.buffer;
|
|
298
|
+
this.buffer = "";
|
|
299
|
+
if (tail.trim() !== "") values.push(parseStreamLine(tail));
|
|
300
|
+
return values;
|
|
301
|
+
}
|
|
302
|
+
const line = this.buffer.slice(0, at);
|
|
303
|
+
this.buffer = this.buffer.slice(at + 1);
|
|
304
|
+
if (line.trim() !== "") values.push(parseStreamLine(line));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function parseStreamLine(line: string): unknown {
|
|
310
|
+
try {
|
|
311
|
+
return JSON.parse(line) as unknown;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`dsh desktop: stream body line is not JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 增量解码 host 侧从请求管道收到的请求帧。
|
|
320
|
+
export class DesktopHostRequestDecoder {
|
|
321
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
322
|
+
|
|
323
|
+
// 追加字节并返回所有已完整的请求帧。
|
|
324
|
+
push(chunk: Buffer): DesktopHostRequestFrame[] {
|
|
325
|
+
this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
326
|
+
const frames: DesktopHostRequestFrame[] = [];
|
|
327
|
+
for (;;) {
|
|
328
|
+
const frame = this.next();
|
|
329
|
+
if (frame === undefined) return frames;
|
|
330
|
+
frames.push(frame);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// EOF 落在帧中间时报错。
|
|
335
|
+
finish(): void {
|
|
336
|
+
if (this.buffer.byteLength !== 0)
|
|
337
|
+
throw new Error("dsh desktop: request pipe ended inside a frame");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private next(): DesktopHostRequestFrame | undefined {
|
|
341
|
+
const total = frameLength(this.buffer, REQUEST_FRAME_DATA, "request");
|
|
342
|
+
if (total === undefined) return undefined;
|
|
343
|
+
const type = this.buffer.readUInt8(4);
|
|
344
|
+
const streamId = this.buffer.readUInt32BE(5);
|
|
345
|
+
const payload = this.buffer.subarray(FRAME_HEADER_BYTES, total);
|
|
346
|
+
this.buffer = this.buffer.subarray(total);
|
|
347
|
+
switch (type) {
|
|
348
|
+
case REQUEST_FRAME_START:
|
|
349
|
+
return { type: "start", streamId, ...this.parseStart(payload) };
|
|
350
|
+
case REQUEST_FRAME_DATA:
|
|
351
|
+
return { type: "data", streamId, data: Buffer.from(payload) };
|
|
352
|
+
case REQUEST_FRAME_END:
|
|
353
|
+
assertEmpty(payload, "request end");
|
|
354
|
+
return { type: "end", streamId };
|
|
355
|
+
case REQUEST_FRAME_CANCEL:
|
|
356
|
+
assertEmpty(payload, "request cancel");
|
|
357
|
+
return { type: "cancel", streamId };
|
|
358
|
+
default:
|
|
359
|
+
throw new Error(`dsh desktop: unknown request frame type ${String(type)}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private parseStart(payload: Buffer): DesktopHostRequestStart {
|
|
364
|
+
const value = parseJson(payload, "request start");
|
|
365
|
+
if (
|
|
366
|
+
!isRecord(value) ||
|
|
367
|
+
typeof value.url !== "string" ||
|
|
368
|
+
typeof value.method !== "string" ||
|
|
369
|
+
!isHeaders(value.headers) ||
|
|
370
|
+
typeof value.hasBody !== "boolean"
|
|
371
|
+
)
|
|
372
|
+
throw new Error("dsh desktop: invalid request start payload");
|
|
373
|
+
return {
|
|
374
|
+
url: value.url,
|
|
375
|
+
method: value.method,
|
|
376
|
+
headers: value.headers,
|
|
377
|
+
hasBody: value.hasBody,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 增量解码壳侧从响应管道收到的响应帧。
|
|
383
|
+
export class DesktopHostResponseDecoder {
|
|
384
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
385
|
+
|
|
386
|
+
// 追加字节并返回所有已完整的响应帧。
|
|
387
|
+
push(chunk: Buffer): DesktopHostResponseFrame[] {
|
|
388
|
+
this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
389
|
+
const frames: DesktopHostResponseFrame[] = [];
|
|
390
|
+
for (;;) {
|
|
391
|
+
const frame = this.next();
|
|
392
|
+
if (frame === undefined) return frames;
|
|
393
|
+
frames.push(frame);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// EOF 落在帧中间时报错。
|
|
398
|
+
finish(): void {
|
|
399
|
+
if (this.buffer.byteLength !== 0)
|
|
400
|
+
throw new Error("dsh desktop: response pipe ended inside a frame");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private next(): DesktopHostResponseFrame | undefined {
|
|
404
|
+
const total = frameLength(this.buffer, RESPONSE_FRAME_DATA, "response");
|
|
405
|
+
if (total === undefined) return undefined;
|
|
406
|
+
const type = this.buffer.readUInt8(4);
|
|
407
|
+
const streamId = this.buffer.readUInt32BE(5);
|
|
408
|
+
const payload = this.buffer.subarray(FRAME_HEADER_BYTES, total);
|
|
409
|
+
this.buffer = this.buffer.subarray(total);
|
|
410
|
+
switch (type) {
|
|
411
|
+
case RESPONSE_FRAME_START:
|
|
412
|
+
return { type: "start", streamId, ...this.parseStart(payload) };
|
|
413
|
+
case RESPONSE_FRAME_DATA:
|
|
414
|
+
return { type: "data", streamId, data: Buffer.from(payload) };
|
|
415
|
+
case RESPONSE_FRAME_END:
|
|
416
|
+
assertEmpty(payload, "response end");
|
|
417
|
+
return { type: "end", streamId };
|
|
418
|
+
case RESPONSE_FRAME_ERROR:
|
|
419
|
+
return { type: "error", streamId, ...this.parseError(payload) };
|
|
420
|
+
default:
|
|
421
|
+
throw new Error(`dsh desktop: unknown response frame type ${String(type)}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private parseStart(
|
|
426
|
+
payload: Buffer,
|
|
427
|
+
): Omit<Extract<DesktopHostResponseFrame, { type: "start" }>, "type" | "streamId"> {
|
|
428
|
+
const value = parseJson(payload, "response start");
|
|
429
|
+
if (
|
|
430
|
+
!isRecord(value) ||
|
|
431
|
+
!Number.isInteger(value.status) ||
|
|
432
|
+
(value.status as number) < 100 ||
|
|
433
|
+
(value.status as number) > 599 ||
|
|
434
|
+
!isHeaders(value.headers) ||
|
|
435
|
+
typeof value.hasBody !== "boolean"
|
|
436
|
+
)
|
|
437
|
+
throw new Error("dsh desktop: invalid response start payload");
|
|
438
|
+
return {
|
|
439
|
+
status: value.status as number,
|
|
440
|
+
headers: value.headers,
|
|
441
|
+
hasBody: value.hasBody,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private parseError(payload: Buffer): { readonly message: string } {
|
|
446
|
+
const value = parseJson(payload, "response error");
|
|
447
|
+
if (!isRecord(value) || typeof value.message !== "string")
|
|
448
|
+
throw new Error("dsh desktop: invalid response error payload");
|
|
449
|
+
return { message: value.message };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// IPC 事件校验(壳侧)。
|
|
454
|
+
export function isDesktopHostEvent(value: unknown): value is DesktopHostEvent {
|
|
455
|
+
if (!isRecord(value)) return false;
|
|
456
|
+
if (value.type === "ready") return value.protocolVersion === DESKTOP_HOST_PROTOCOL_VERSION;
|
|
457
|
+
if (value.type === "fatal")
|
|
458
|
+
return (
|
|
459
|
+
typeof value.message === "string" &&
|
|
460
|
+
(value.diagnostic === undefined || typeof value.diagnostic === "string")
|
|
461
|
+
);
|
|
462
|
+
if (value.type === "inspected")
|
|
463
|
+
return (
|
|
464
|
+
(value.url === null || typeof value.url === "string") &&
|
|
465
|
+
(value.message === undefined || typeof value.message === "string")
|
|
466
|
+
);
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// IPC 命令校验(host 侧)。
|
|
471
|
+
export function isDesktopHostCommand(value: unknown): value is DesktopHostCommand {
|
|
472
|
+
if (!isRecord(value)) return false;
|
|
473
|
+
if (value.type === "shutdown" || value.type === "inspect-off") return true;
|
|
474
|
+
return (
|
|
475
|
+
value.type === "inspect" &&
|
|
476
|
+
Number.isInteger(value.port) &&
|
|
477
|
+
(value.port as number) >= 0 &&
|
|
478
|
+
(value.port as number) <= 65_535
|
|
479
|
+
);
|
|
480
|
+
}
|