@morlay/dsh-desktop-shell 0.2.5 → 0.2.7

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.
Files changed (51) hide show
  1. package/README.md +4 -4
  2. package/dist/{appconfig-BZ9wPROk.mjs → appconfig-CSZh3JG7.mjs} +2 -2
  3. package/dist/appconfig.d.mts +21 -0
  4. package/dist/appconfig.mjs +1 -2
  5. package/dist/{dshhome-Dp89FOr-.mjs → dshhome-DAJ9dkn2.mjs} +2 -2
  6. package/dist/dshhome.d.mts +6 -0
  7. package/dist/dshhome.mjs +1 -2
  8. package/dist/index.d.mts +6 -0
  9. package/dist/index.mjs +1050 -139
  10. package/dist/inline-packages.d.mts +14 -0
  11. package/dist/inline-packages.mjs +21 -0
  12. package/dist/official-BmgBsdhq.mjs +5 -0
  13. package/dist/official.d.mts +4 -0
  14. package/dist/official.mjs +1 -2
  15. package/dist/preload-app.cjs +5 -1
  16. package/dist/profile-project.d.mts +20 -0
  17. package/dist/profile-project.mjs +1 -2
  18. package/dist/{seed-BF71UAuI.mjs → seed-Bxr0BsCk.mjs} +52 -5
  19. package/dist/seed.d.mts +21 -0
  20. package/dist/seed.mjs +1 -2
  21. package/package.json +44 -22
  22. package/src/__tests__/developer-endpoint.spec.ts +17 -0
  23. package/src/__tests__/document-marks.spec.ts +42 -0
  24. package/src/__tests__/dshhome.spec.ts +108 -0
  25. package/src/__tests__/host-process.spec.ts +205 -0
  26. package/src/__tests__/ipc.spec.ts +39 -0
  27. package/src/__tests__/profile-project.spec.ts +204 -0
  28. package/src/__tests__/seed-profile.spec.ts +189 -0
  29. package/src/__tests__/shell-env.spec.ts +131 -0
  30. package/src/__tests__/shell-import-boundary.spec.ts +69 -0
  31. package/src/__tests__/stream-uplink.spec.ts +49 -0
  32. package/src/appconfig.ts +79 -0
  33. package/src/developer-endpoint.ts +30 -0
  34. package/src/developer-menu.ts +198 -0
  35. package/src/directory-picker.ts +37 -0
  36. package/src/document-marks.ts +58 -0
  37. package/src/dshhome.ts +29 -0
  38. package/src/electron.ts +33 -0
  39. package/src/host-process.ts +533 -0
  40. package/src/index.ts +716 -0
  41. package/src/inline-packages.ts +18 -0
  42. package/src/ipc.ts +58 -0
  43. package/src/keybindings.ts +35 -0
  44. package/src/official.ts +8 -0
  45. package/src/preload-app.ts +147 -0
  46. package/src/profile-project.ts +138 -0
  47. package/src/seed.ts +183 -0
  48. package/src/shell-env.ts +31 -0
  49. package/src/shortcuts.ts +236 -0
  50. package/src/stream-uplink.ts +41 -0
  51. package/dist/official-Cz9Mry1n.mjs +0 -167
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `electron` 模块的**唯一入口**(壳自己的其它文件都从这里取)。
3
+ *
4
+ * 壳产物是 **ESM**(`.mjs`,Electron 28+ 支持 ESM 入口),而 `electron` 在 ESM 下**只有默认导出**:具名导入
5
+ * 会抛 `SyntaxError: The requested module 'electron' does not provide an export named 'BrowserWindow'`,
6
+ * 主进程在 `load` 阶段就没了。更麻烦的是**打包器会把"默认导入 + 解构 / 取属性"优化回具名导入**——那样又回到
7
+ * 同一个错误。
8
+ *
9
+ * 所以这里**走运行期 `require`**:壳就是 Electron 主进程,`require("electron")` 正是它给这个模块的原生形态,
10
+ * 打包器碰不到它。类型仍从 `electron` 取(类型导入会被擦除,不影响运行)。
11
+ */
12
+ import { createRequire } from "node:module";
13
+
14
+ const electron = createRequire(import.meta.url)("electron") as typeof import("electron");
15
+
16
+ export const app = electron.app;
17
+ export const BrowserWindow = electron.BrowserWindow;
18
+ export const dialog = electron.dialog;
19
+ export const ipcMain = electron.ipcMain;
20
+ export const nativeTheme = electron.nativeTheme;
21
+ export const protocol = electron.protocol;
22
+ export const Menu = electron.Menu;
23
+ export const MenuItem = electron.MenuItem;
24
+
25
+ // 类型仍从 `electron` 取:`import type` 会被擦除,不进运行字节;而 `BrowserWindow` / `Menu` 这类
26
+ // **既是值又是类型**的名字不能在这里再导一次(同名重复导出 TS 直接报错),用的人自己
27
+ // `import type { BrowserWindow } from "electron"`。
28
+ export type {
29
+ BrowserWindowConstructorOptions,
30
+ Input,
31
+ IpcMainInvokeEvent,
32
+ MenuItemConstructorOptions,
33
+ } from "electron";
@@ -0,0 +1,533 @@
1
+ import { appendFile } from "node:fs/promises";
2
+ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import { Readable, Writable } from "node:stream";
5
+ import {
6
+ DESKTOP_HOST_PROTOCOL_VERSION,
7
+ DESKTOP_PIPE_CHUNK_BYTES,
8
+ DesktopHostResponseDecoder,
9
+ encodeDesktopRequestCancel,
10
+ encodeDesktopRequestData,
11
+ encodeDesktopRequestEnd,
12
+ encodeDesktopRequestStart,
13
+ isDesktopHostEvent,
14
+ type DesktopHostCommand,
15
+ type DesktopHostEvent,
16
+ type DesktopHostResponseFrame,
17
+ } from "@morlay/dsh-desktop-host/wire";
18
+
19
+ const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 1024;
20
+
21
+ interface PendingResponse {
22
+ readonly resolve: (response: Response) => void;
23
+ readonly reject: (error: Error) => void;
24
+ responseStarted: boolean;
25
+ uploadOpen: boolean;
26
+ controller?: ReadableStreamDefaultController<Uint8Array>;
27
+ requestReader?: ReadableStreamDefaultReader<Uint8Array>;
28
+ removeAbort?: () => void;
29
+ }
30
+
31
+ function errorOf(reason: unknown, fallback: string): Error {
32
+ return reason instanceof Error ? reason : new Error(fallback);
33
+ }
34
+
35
+ async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<boolean> {
36
+ let timer: ReturnType<typeof setTimeout> | undefined;
37
+ const timeout = new Promise<false>((resolve) => {
38
+ timer = setTimeout(() => {
39
+ resolve(false);
40
+ }, milliseconds);
41
+ timer.unref();
42
+ });
43
+ try {
44
+ return await Promise.race([exit.then(() => true), timeout]);
45
+ } finally {
46
+ if (timer !== undefined) clearTimeout(timer);
47
+ }
48
+ }
49
+
50
+ export interface DesktopHostReady {
51
+ readonly protocolVersion: number;
52
+ /** 宿主进程内回环的 ws 端口(页面把 `ws://app/…` 重写到这里,见 `@morlay/dsh-desktop-host/ws-bridge`)。 */
53
+ /** 宿主 webServer 的 origin(`http://127.0.0.1:<port>`):窗口就是载入它。 */
54
+ readonly origin: string;
55
+ }
56
+
57
+ // 后端调试端点的状态:`url` 为空表示没开(被关掉或开不起来,原因在 `message`)。
58
+ export type DesktopHostInspected = Extract<DesktopHostEvent, { type: "inspected" }>;
59
+
60
+ export type DesktopHostSpawn = (
61
+ command: string,
62
+ args: readonly string[],
63
+ options: SpawnOptions,
64
+ ) => ChildProcess;
65
+
66
+ export interface DesktopHostOptions {
67
+ /**
68
+ * 宿主入口(绝对路径):由壳按 node 的解析给出(`@morlay/dsh-desktop-host` 的 exports 指到
69
+ * `src/index.ts`)。宿主由壳用 `--import` 挂着 TS loader 起,**两种形态都直接跑源码**——部署里不必再落
70
+ * 一份 dist 载荷。
71
+ */
72
+ readonly entry: string;
73
+ readonly nodeArgs?: readonly string[];
74
+
75
+ readonly extraEnv?: Readonly<Record<string, string>>;
76
+
77
+ readonly packageManager?: { readonly pnpm: string; readonly nodeBin: string };
78
+
79
+ // 子进程在 `ps` 里的名字(`node --title`),由壳给成 `<app name>-server`。
80
+ readonly processTitle?: string;
81
+
82
+ readonly spawn?: DesktopHostSpawn;
83
+
84
+ // host 非预期退出/管道断开时通知壳一次(壳据此拦截,别让页面停在半死状态)。
85
+ readonly onFailure?: (error: Error) => void;
86
+
87
+ /**
88
+ * 宿主输出的落盘位置(壳给 `$DSH_HOME/logs/host.log`)。
89
+ *
90
+ * 打包形态下 GUI 的 stdout/stderr 没人看得到,而"某个 bundle/某一行没起来"的第一现场就在宿主这份输出里
91
+ * ——不落盘就只能看到"服务少了",看不到为什么。
92
+ */
93
+ readonly logFile?: string;
94
+ }
95
+
96
+ // 桌面 host 子进程:字节管道上的请求/响应载体(FD 3/4)+ Node IPC 生命周期。
97
+ export class DesktopHostProcess {
98
+ private child: ChildProcess | undefined;
99
+ private requestPipe: Writable | undefined;
100
+ private responsePipe: Readable | undefined;
101
+ private readonly responseDecoder = new DesktopHostResponseDecoder();
102
+ private requestWriteTail: Promise<void> = Promise.resolve();
103
+ private nextStreamId = 1;
104
+ private readonly pending = new Map<number, PendingResponse>();
105
+ private readonly blockedResponses = new Set<number>();
106
+ private readonly inspectedListeners = new Set<(state: DesktopHostInspected) => void>();
107
+ private readyResolve!: (ready: DesktopHostReady) => void;
108
+ private readyReject!: (error: Error) => void;
109
+ private readonly readyPromise = new Promise<DesktopHostReady>((resolve, reject) => {
110
+ this.readyResolve = resolve;
111
+ this.readyReject = reject;
112
+ });
113
+ private exitPromise: Promise<void> | undefined;
114
+ private stderr = "";
115
+ private failureReported = false;
116
+ private stopping = false;
117
+
118
+ constructor(
119
+ private readonly node: string,
120
+ private readonly runtimeDir: string,
121
+ private readonly profileDir: string,
122
+ private readonly inspectPort: number | undefined,
123
+ private readonly options: DesktopHostOptions,
124
+ ) {}
125
+
126
+ // 启动子进程一次;`ready` 之后才解析。
127
+ async start(): Promise<DesktopHostReady> {
128
+ if (this.child !== undefined) return this.readyPromise;
129
+ const entry = this.options.entry;
130
+ const packageManager = this.options.packageManager;
131
+ const args = [
132
+ "--expose-internals",
133
+ ...(this.options.processTitle === undefined ? [] : [`--title=${this.options.processTitle}`]),
134
+ ...(this.inspectPort === undefined
135
+ ? []
136
+ : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
137
+ ...(this.options.nodeArgs ?? []),
138
+ entry,
139
+ // 宿主入口的参数与 web 入口**同一套**(`@morlay/dsh-desktop-host` 的 `invocation.ts`):壳只给
140
+ // 两格锚点与包通道,形态自己的内层参数(`--no-open` 等)由宿主入口自己补——传参形状两边一致,
141
+ // 才不会出现"dev 对、打包不对"这种只差一处的分歧。
142
+ "--profile-dir",
143
+ this.profileDir,
144
+ "--runtime-dir",
145
+ this.runtimeDir,
146
+ ...(packageManager === undefined
147
+ ? []
148
+ : ["--pnpm", packageManager.pnpm, "--node-bin", packageManager.nodeBin]),
149
+ ];
150
+ const env = {
151
+ ...Object.fromEntries(
152
+ Object.entries(process.env).filter(
153
+ ([name]) =>
154
+ name !== "NODE_OPTIONS" &&
155
+ !name.startsWith("DSH_DESKTOP_") &&
156
+ !/^(?:npm|pnpm|corepack)_/iu.test(name),
157
+ ),
158
+ ),
159
+ ...this.options.extraEnv,
160
+ };
161
+ const spawnChild = this.options.spawn ?? spawn;
162
+ const child = spawnChild(this.node, args, {
163
+ cwd: this.profileDir,
164
+ env,
165
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
166
+ });
167
+ this.child = child;
168
+ // 宿主输出除转发之外**再落一份盘**(见 `logFile`):打包形态 GUI 里看不到 stdout/stderr,
169
+ // 而"少服务"的原因只在宿主这份输出里。
170
+ const persist = (chunk: string): void => {
171
+ if (this.options.logFile === undefined) return;
172
+ void appendFile(this.options.logFile, chunk).catch(() => undefined);
173
+ };
174
+ child.stderr?.setEncoding("utf8");
175
+ child.stderr?.on("data", (chunk: string) => {
176
+ this.stderr = (this.stderr + chunk).slice(-MAX_HOST_DIAGNOSTIC_CHARS);
177
+ process.stderr.write(`[dsh-host] ${chunk}`);
178
+ persist(chunk);
179
+ });
180
+ child.stdout?.setEncoding("utf8");
181
+ child.stdout?.on("data", (chunk: string) => {
182
+ process.stdout.write(chunk);
183
+ persist(chunk);
184
+ });
185
+ child.on("message", (message: unknown) => {
186
+ if (!isDesktopHostEvent(message)) {
187
+ this.fail(new Error("dsh desktop host sent an invalid IPC event"));
188
+ this.killChild("SIGTERM");
189
+ return;
190
+ }
191
+ if (message.type === "fatal") {
192
+ // dialog 只显示 message;完整诊断(code / syscall / path / cause)写 stderr——
193
+ // fatal 带可选 `diagnostic`;不接收就等于丢掉启动失败的根因。
194
+ if (message.diagnostic !== undefined)
195
+ process.stderr.write(`[dsh-host] ${message.diagnostic}\n`);
196
+ this.fail(new Error(message.message));
197
+ return;
198
+ }
199
+ if (message.type === "inspected") {
200
+ for (const listener of this.inspectedListeners) listener(message);
201
+ return;
202
+ }
203
+ this.readyResolve({
204
+ protocolVersion: message.protocolVersion,
205
+ origin: message.origin,
206
+ });
207
+ });
208
+ child.once("error", (error) => {
209
+ this.fail(error);
210
+ });
211
+ this.exitPromise = new Promise<void>((resolve) => {
212
+ child.once("close", (code) => {
213
+ const suffix = this.stderr.trim() === "" ? "" : `: ${this.stderr.trim()}`;
214
+ if (code !== 0 && code !== null)
215
+ this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`));
216
+ else this.fail(new Error(`dsh desktop host stopped${suffix}`));
217
+ resolve();
218
+ });
219
+ });
220
+ return this.readyPromise;
221
+ }
222
+
223
+ // 把一个应用协议请求(`<scheme>://app/*`)交给子进程,响应体边收边出。
224
+ async fetch(request: Request): Promise<Response> {
225
+ await this.start();
226
+ const child = this.child;
227
+ if (child === undefined || !child.connected || this.requestPipe === undefined)
228
+ throw new Error("dsh desktop host is unavailable");
229
+ if (this.nextStreamId > 0xffff_ffff)
230
+ throw new Error("dsh desktop host exhausted its request stream ids");
231
+ const streamId = this.nextStreamId++;
232
+ const method = request.method.toUpperCase();
233
+ const hasBody = method !== "GET" && method !== "HEAD" && request.body !== null;
234
+ return new Promise<Response>((resolve, reject) => {
235
+ const pending: PendingResponse = {
236
+ resolve,
237
+ reject,
238
+ responseStarted: false,
239
+ uploadOpen: hasBody,
240
+ };
241
+ const abort = (): void => {
242
+ if (!this.pending.has(streamId)) return;
243
+ const error = errorOf(request.signal.reason, "request aborted");
244
+ pending.uploadOpen = false;
245
+ void pending.requestReader?.cancel(error).catch(() => undefined);
246
+ this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch(
247
+ (pipeError: unknown) => {
248
+ this.fail(errorOf(pipeError, "dsh desktop request pipe failed"));
249
+ },
250
+ );
251
+ if (pending.controller === undefined) pending.reject(error);
252
+ else pending.controller.error(error);
253
+ this.finishPending(streamId, false);
254
+ };
255
+ if (request.signal.aborted) {
256
+ reject(errorOf(request.signal.reason, "request aborted"));
257
+ return;
258
+ }
259
+ request.signal.addEventListener("abort", abort, { once: true });
260
+ pending.removeAbort = () => {
261
+ request.signal.removeEventListener("abort", abort);
262
+ };
263
+ this.pending.set(streamId, pending);
264
+ this.pumpRequest(streamId, request, hasBody).catch((error: unknown) => {
265
+ this.failPending(streamId, errorOf(error, "dsh desktop request upload failed"));
266
+ });
267
+ });
268
+ }
269
+
270
+ // 打开后端调试端点(`port` 为 0 表示由 host 挑一个随机端口);端点经 `onInspected` 回报。
271
+ inspect(port = 0): void {
272
+ this.send({ type: "inspect", port });
273
+ }
274
+
275
+ // 关掉后端调试端点。
276
+ endInspect(): void {
277
+ this.send({ type: "inspect-off" });
278
+ }
279
+
280
+ // 订阅调试端点状态;返回退订。
281
+ onInspected(listener: (state: DesktopHostInspected) => void): () => void {
282
+ this.inspectedListeners.add(listener);
283
+ return () => {
284
+ this.inspectedListeners.delete(listener);
285
+ };
286
+ }
287
+
288
+ // 请求优雅退出,再等子进程收尾。
289
+ async stop(): Promise<void> {
290
+ const child = this.child;
291
+ if (child === undefined) return;
292
+ this.stopping = true;
293
+ this.blockedResponses.clear();
294
+ this.responsePipe?.resume();
295
+ if (child.connected) this.send({ type: "shutdown" });
296
+ // 关掉父进程这一侧的写端,让 host 侧挂起的管道读在 Windows 上也能释放。
297
+ this.requestPipe?.destroy();
298
+ const exited = this.exitPromise ?? Promise.resolve();
299
+ if (!(await exitsWithin(exited, 10_000))) this.killChild("SIGTERM");
300
+ if (!(await exitsWithin(exited, 5_000))) {
301
+ this.killChild("SIGKILL");
302
+ if (!(await exitsWithin(exited, 5_000)))
303
+ throw new Error("dsh desktop host did not exit after SIGKILL");
304
+ }
305
+ this.child = undefined;
306
+ this.requestPipe = undefined;
307
+ this.responsePipe = undefined;
308
+ }
309
+
310
+ private async pumpRequest(streamId: number, request: Request, hasBody: boolean): Promise<void> {
311
+ await this.enqueueRequestFrame(
312
+ encodeDesktopRequestStart(streamId, {
313
+ url: request.url,
314
+ method: request.method.toUpperCase(),
315
+ headers: [...request.headers.entries()],
316
+ hasBody,
317
+ }),
318
+ );
319
+ if (!hasBody) return;
320
+ const body = request.body;
321
+ if (body === null) throw new Error("dsh desktop request body disappeared before upload");
322
+ const reader = body.getReader();
323
+ const pending = this.pending.get(streamId);
324
+ if (pending === undefined) {
325
+ await reader.cancel();
326
+ return;
327
+ }
328
+ pending.requestReader = reader;
329
+ try {
330
+ for (;;) {
331
+ const next = await reader.read();
332
+ if (next.done) break;
333
+ for (let offset = 0; offset < next.value.byteLength; offset += DESKTOP_PIPE_CHUNK_BYTES) {
334
+ if (!this.pending.has(streamId)) return;
335
+ await this.enqueueRequestFrame(
336
+ encodeDesktopRequestData(
337
+ streamId,
338
+ next.value.subarray(offset, offset + DESKTOP_PIPE_CHUNK_BYTES),
339
+ ),
340
+ );
341
+ }
342
+ }
343
+ const live = this.pending.get(streamId);
344
+ if (live !== undefined) {
345
+ await this.enqueueRequestFrame(encodeDesktopRequestEnd(streamId));
346
+ live.uploadOpen = false;
347
+ }
348
+ } finally {
349
+ reader.releaseLock();
350
+ const live = this.pending.get(streamId);
351
+ if (live?.requestReader === reader) delete live.requestReader;
352
+ }
353
+ }
354
+
355
+ private enqueueRequestFrame(frame: Buffer): Promise<void> {
356
+ const write = this.requestWriteTail.then(async () => {
357
+ const pipe = this.requestPipe;
358
+ if (pipe === undefined || pipe.destroyed)
359
+ throw new Error("dsh desktop host request pipe is unavailable");
360
+ if (!pipe.write(frame)) await once(pipe, "drain");
361
+ });
362
+ this.requestWriteTail = write.catch(() => undefined);
363
+ return write;
364
+ }
365
+
366
+ private send(message: DesktopHostCommand): void {
367
+ const child = this.child;
368
+ if (child === undefined || !child.connected)
369
+ throw new Error("dsh desktop host IPC is unavailable");
370
+ child.send(message, (error) => {
371
+ if (error !== null) this.fail(error);
372
+ });
373
+ }
374
+
375
+ private acceptResponseBytes(chunk: Buffer): void {
376
+ try {
377
+ for (const frame of this.responseDecoder.push(chunk)) this.handleResponseFrame(frame);
378
+ } catch (error) {
379
+ this.fail(errorOf(error, "dsh desktop host response pipe failed"));
380
+ this.killChild("SIGTERM");
381
+ }
382
+ }
383
+
384
+ private handleResponseFrame(frame: DesktopHostResponseFrame): void {
385
+ const pending = this.pending.get(frame.streamId);
386
+ if (pending === undefined) {
387
+ if (frame.streamId >= this.nextStreamId)
388
+ throw new Error(`dsh desktop host responded for unknown stream ${String(frame.streamId)}`);
389
+ return;
390
+ }
391
+ switch (frame.type) {
392
+ case "start": {
393
+ if (pending.responseStarted)
394
+ throw new Error(`dsh desktop host started stream ${String(frame.streamId)} twice`);
395
+ pending.responseStarted = true;
396
+ let body: ReadableStream<Uint8Array> | null = null;
397
+ if (frame.hasBody) {
398
+ body = new ReadableStream<Uint8Array>({
399
+ start: (controller) => {
400
+ pending.controller = controller;
401
+ },
402
+ pull: () => {
403
+ this.blockedResponses.delete(frame.streamId);
404
+ this.resumeResponsePipe();
405
+ },
406
+ cancel: (reason) => {
407
+ this.cancelResponse(frame.streamId, reason);
408
+ },
409
+ });
410
+ }
411
+ pending.resolve(
412
+ new Response(body, {
413
+ status: frame.status,
414
+ headers: new Headers(frame.headers.map(([name, value]) => [name, value])),
415
+ }),
416
+ );
417
+ return;
418
+ }
419
+ case "data": {
420
+ const controller = pending.controller;
421
+ if (!pending.responseStarted || controller === undefined)
422
+ throw new Error(
423
+ `dsh desktop host sent body data before a body start for stream ${String(frame.streamId)}`,
424
+ );
425
+ controller.enqueue(frame.data);
426
+ if ((controller.desiredSize ?? 0) <= 0) {
427
+ this.blockedResponses.add(frame.streamId);
428
+ this.responsePipe?.pause();
429
+ }
430
+ return;
431
+ }
432
+ case "end":
433
+ if (!pending.responseStarted)
434
+ throw new Error(
435
+ `dsh desktop host ended stream ${String(frame.streamId)} before its response start`,
436
+ );
437
+ pending.controller?.close();
438
+ this.finishPending(frame.streamId, true);
439
+ return;
440
+ case "error":
441
+ this.failPending(frame.streamId, new Error(frame.message));
442
+ return;
443
+ default:
444
+ return;
445
+ }
446
+ }
447
+
448
+ private cancelResponse(streamId: number, reason: unknown): void {
449
+ const pending = this.pending.get(streamId);
450
+ if (pending === undefined) return;
451
+ pending.uploadOpen = false;
452
+ void pending.requestReader?.cancel(reason).catch(() => undefined);
453
+ this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error: unknown) => {
454
+ this.fail(errorOf(error, "dsh desktop request pipe failed"));
455
+ });
456
+ this.finishPending(streamId, false);
457
+ }
458
+
459
+ private failPending(streamId: number, error: Error): void {
460
+ const pending = this.pending.get(streamId);
461
+ if (pending === undefined) return;
462
+ pending.uploadOpen = false;
463
+ void pending.requestReader?.cancel(error).catch(() => undefined);
464
+ if (pending.controller === undefined) pending.reject(error);
465
+ else pending.controller.error(error);
466
+ this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError: unknown) => {
467
+ this.fail(errorOf(pipeError, "dsh desktop request pipe failed"));
468
+ });
469
+ this.finishPending(streamId, false);
470
+ }
471
+
472
+ private finishPending(streamId: number, cancelOpenUpload: boolean): void {
473
+ const pending = this.pending.get(streamId);
474
+ if (pending === undefined) return;
475
+ if (cancelOpenUpload && pending.uploadOpen) {
476
+ pending.uploadOpen = false;
477
+ void pending.requestReader?.cancel().catch(() => undefined);
478
+ this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error: unknown) => {
479
+ this.fail(errorOf(error, "dsh desktop request pipe failed"));
480
+ });
481
+ }
482
+ pending.removeAbort?.();
483
+ this.pending.delete(streamId);
484
+ this.blockedResponses.delete(streamId);
485
+ this.resumeResponsePipe();
486
+ }
487
+
488
+ private resumeResponsePipe(): void {
489
+ if (this.blockedResponses.size === 0) this.responsePipe?.resume();
490
+ }
491
+
492
+ private killChild(signal: NodeJS.Signals): void {
493
+ const child = this.child;
494
+ if (child === undefined || child.pid === undefined) return;
495
+ if (process.platform !== "win32") {
496
+ try {
497
+ process.kill(-child.pid, signal);
498
+ return;
499
+ } catch (error) {
500
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
501
+ }
502
+ }
503
+ child.kill(signal);
504
+ }
505
+
506
+ private fail(error: Error): void {
507
+ if (!this.stopping) this.readyReject(error);
508
+ if (!this.failureReported && !this.stopping) {
509
+ this.failureReported = true;
510
+ try {
511
+ this.options.onFailure?.(error);
512
+ } catch (listenerError) {
513
+ // 监听器自己抛错:这条通道可能已经坏了,记不了就只留一行 stderr。
514
+ console.error(listenerError);
515
+ }
516
+ }
517
+ // 收尾阶段(壳主动 stop)在途请求回 503 即可,别把管道关闭当异常抛给渲染进程。
518
+ const closing = this.stopping;
519
+ for (const pending of this.pending.values()) {
520
+ void pending.requestReader?.cancel(error).catch(() => undefined);
521
+ if (closing) {
522
+ pending.resolve(new Response("desktop host is shutting down", { status: 503 }));
523
+ } else if (pending.controller === undefined) pending.reject(error);
524
+ else pending.controller.error(error);
525
+ pending.removeAbort?.();
526
+ }
527
+ this.pending.clear();
528
+ this.blockedResponses.clear();
529
+ this.responsePipe?.resume();
530
+ }
531
+ }
532
+
533
+ export { DESKTOP_HOST_PROTOCOL_VERSION };