@morlay/dsh-desktop-host 0.2.4 → 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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { DEFAULT_WEB_PORT, webArgs } from "../web.ts";
|
|
3
|
+
|
|
4
|
+
describe("web 端口契约", () => {
|
|
5
|
+
it("内层没给 --port 时补上 PORT(缺省 0)", () => {
|
|
6
|
+
expect(webArgs([], DEFAULT_WEB_PORT)).toEqual(["--port", "0"]);
|
|
7
|
+
expect(webArgs(["--no-open"], "4000")).toEqual(["--port", "4000", "--no-open"]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("内层显式给了 --port 就以它为准", () => {
|
|
11
|
+
expect(webArgs(["--port", "8080"], "4000")).toEqual(["--port", "8080"]);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
DESKTOP_HOST_PROTOCOL_VERSION,
|
|
4
|
+
DESKTOP_PIPE_CHUNK_BYTES,
|
|
5
|
+
DesktopHostRequestDecoder,
|
|
6
|
+
DesktopHostResponseDecoder,
|
|
7
|
+
DesktopStreamBodyDecoder,
|
|
8
|
+
encodeDesktopRequestCancel,
|
|
9
|
+
encodeDesktopRequestData,
|
|
10
|
+
encodeDesktopRequestEnd,
|
|
11
|
+
encodeDesktopRequestStart,
|
|
12
|
+
encodeDesktopResponseData,
|
|
13
|
+
encodeDesktopResponseEnd,
|
|
14
|
+
encodeDesktopResponseError,
|
|
15
|
+
encodeDesktopResponseStart,
|
|
16
|
+
encodeDesktopStreamItem,
|
|
17
|
+
encodeDesktopStreamOpen,
|
|
18
|
+
isDesktopHostCommand,
|
|
19
|
+
isDesktopHostEvent,
|
|
20
|
+
} from "../wire.ts";
|
|
21
|
+
|
|
22
|
+
function decodeRequests(...chunks: Buffer[]) {
|
|
23
|
+
const decoder = new DesktopHostRequestDecoder();
|
|
24
|
+
const frames = chunks.flatMap((chunk) => decoder.push(chunk));
|
|
25
|
+
decoder.finish();
|
|
26
|
+
return frames;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function decodeResponses(...chunks: Buffer[]) {
|
|
30
|
+
const decoder = new DesktopHostResponseDecoder();
|
|
31
|
+
const frames = chunks.flatMap((chunk) => decoder.push(chunk));
|
|
32
|
+
decoder.finish();
|
|
33
|
+
return frames;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("请求帧", () => {
|
|
37
|
+
it("按序还原 start / data / end", () => {
|
|
38
|
+
const frames = decodeRequests(
|
|
39
|
+
Buffer.concat([
|
|
40
|
+
encodeDesktopRequestStart(7, {
|
|
41
|
+
url: "dsh-app://app/api/session",
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: [["content-type", "application/json"]],
|
|
44
|
+
hasBody: true,
|
|
45
|
+
}),
|
|
46
|
+
encodeDesktopRequestData(7, Buffer.from("body")),
|
|
47
|
+
encodeDesktopRequestEnd(7),
|
|
48
|
+
]),
|
|
49
|
+
);
|
|
50
|
+
expect(frames).toEqual([
|
|
51
|
+
{
|
|
52
|
+
type: "start",
|
|
53
|
+
streamId: 7,
|
|
54
|
+
url: "dsh-app://app/api/session",
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: [["content-type", "application/json"]],
|
|
57
|
+
hasBody: true,
|
|
58
|
+
},
|
|
59
|
+
{ type: "data", streamId: 7, data: Buffer.from("body") },
|
|
60
|
+
{ type: "end", streamId: 7 },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("跨分片投递时按帧补齐", () => {
|
|
65
|
+
const bytes = Buffer.concat([
|
|
66
|
+
encodeDesktopRequestStart(1, {
|
|
67
|
+
url: "dsh-app://app/",
|
|
68
|
+
method: "GET",
|
|
69
|
+
headers: [],
|
|
70
|
+
hasBody: false,
|
|
71
|
+
}),
|
|
72
|
+
encodeDesktopRequestCancel(1),
|
|
73
|
+
]);
|
|
74
|
+
const decoder = new DesktopHostRequestDecoder();
|
|
75
|
+
const frames = [];
|
|
76
|
+
for (const byte of bytes) frames.push(...decoder.push(Buffer.from([byte])));
|
|
77
|
+
decoder.finish();
|
|
78
|
+
expect(frames.map((frame) => frame.type)).toEqual(["start", "cancel"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("载荷长度错误的空帧与未知类型都报错", () => {
|
|
82
|
+
const start = encodeDesktopRequestStart(1, {
|
|
83
|
+
url: "dsh-app://app/",
|
|
84
|
+
method: "GET",
|
|
85
|
+
headers: [],
|
|
86
|
+
hasBody: false,
|
|
87
|
+
});
|
|
88
|
+
const cancel = encodeDesktopRequestCancel(1);
|
|
89
|
+
// 载荷长度字段被写成超过控制帧上限,解码要在读取前拒绝。
|
|
90
|
+
const tampered = Buffer.from(cancel);
|
|
91
|
+
tampered.writeUInt32BE(2 * 1024 * 1024, 9);
|
|
92
|
+
expect(() => decodeRequests(Buffer.concat([start, tampered]))).toThrow(/exceeds/);
|
|
93
|
+
|
|
94
|
+
const truncated = Buffer.from(cancel);
|
|
95
|
+
truncated.writeUInt32BE(1, 9);
|
|
96
|
+
expect(() => decodeRequests(Buffer.concat([start, truncated]))).toThrow(/inside a frame/);
|
|
97
|
+
|
|
98
|
+
const unknown = Buffer.from(start);
|
|
99
|
+
unknown.writeUInt8(9, 4);
|
|
100
|
+
expect(() => decodeRequests(unknown)).toThrow(/unknown request frame type/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("EOF 落在帧中间时报错", () => {
|
|
104
|
+
const decoder = new DesktopHostRequestDecoder();
|
|
105
|
+
decoder.push(
|
|
106
|
+
encodeDesktopRequestStart(1, {
|
|
107
|
+
url: "dsh-app://app/",
|
|
108
|
+
method: "GET",
|
|
109
|
+
headers: [],
|
|
110
|
+
hasBody: false,
|
|
111
|
+
}).subarray(0, 8),
|
|
112
|
+
);
|
|
113
|
+
expect(() => decoder.finish()).toThrow(/inside a frame/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("data 帧超过单帧上限时编码报错", () => {
|
|
117
|
+
expect(() => encodeDesktopRequestData(1, Buffer.alloc(DESKTOP_PIPE_CHUNK_BYTES + 1))).toThrow(
|
|
118
|
+
/exceeds/,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("响应帧", () => {
|
|
124
|
+
it("按序还原 start / data / end", () => {
|
|
125
|
+
const frames = decodeResponses(
|
|
126
|
+
Buffer.concat([
|
|
127
|
+
encodeDesktopResponseStart(3, {
|
|
128
|
+
status: 200,
|
|
129
|
+
headers: [["content-type", "text/html"]],
|
|
130
|
+
hasBody: true,
|
|
131
|
+
}),
|
|
132
|
+
encodeDesktopResponseData(3, Buffer.from("hi")),
|
|
133
|
+
encodeDesktopResponseEnd(3),
|
|
134
|
+
]),
|
|
135
|
+
);
|
|
136
|
+
expect(frames).toEqual([
|
|
137
|
+
{
|
|
138
|
+
type: "start",
|
|
139
|
+
streamId: 3,
|
|
140
|
+
status: 200,
|
|
141
|
+
headers: [["content-type", "text/html"]],
|
|
142
|
+
hasBody: true,
|
|
143
|
+
},
|
|
144
|
+
{ type: "data", streamId: 3, data: Buffer.from("hi") },
|
|
145
|
+
{ type: "end", streamId: 3 },
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("错误帧携带消息", () => {
|
|
150
|
+
expect(decodeResponses(encodeDesktopResponseError(4, "boom"))).toEqual([
|
|
151
|
+
{ type: "error", streamId: 4, message: "boom" },
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("非法帧标记报错", () => {
|
|
156
|
+
const bad = Buffer.from(encodeDesktopResponseEnd(1));
|
|
157
|
+
bad.writeUInt32BE(0xdead_beef, 0);
|
|
158
|
+
expect(() => decodeResponses(bad)).toThrow(/frame marker/);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("桌面流请求体", () => {
|
|
163
|
+
it("首行定 endpoint 与 payload,后续行是上行项", () => {
|
|
164
|
+
const decoder = new DesktopStreamBodyDecoder();
|
|
165
|
+
const text =
|
|
166
|
+
encodeDesktopStreamOpen("/session.follow", { args: { sessionId: "s1" } }) +
|
|
167
|
+
encodeDesktopStreamItem({ typed: "hi" }) +
|
|
168
|
+
encodeDesktopStreamItem(7);
|
|
169
|
+
expect(decoder.push(Buffer.from(text, "utf8"))).toEqual([
|
|
170
|
+
{ endpoint: "/session.follow", payload: { args: { sessionId: "s1" } } },
|
|
171
|
+
{ typed: "hi" },
|
|
172
|
+
7,
|
|
173
|
+
]);
|
|
174
|
+
expect(decoder.finish()).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("行被切进不同字节块、多字节字符被切开也能还原", () => {
|
|
178
|
+
const decoder = new DesktopStreamBodyDecoder();
|
|
179
|
+
const bytes = Buffer.from(encodeDesktopStreamItem({ text: "撤回并重试" }), "utf8");
|
|
180
|
+
// 每 3 字节一块:中文的三字节序列正好被切开。
|
|
181
|
+
const values: unknown[] = [];
|
|
182
|
+
for (let offset = 0; offset < bytes.length; offset += 3)
|
|
183
|
+
values.push(...decoder.push(bytes.subarray(offset, offset + 3)));
|
|
184
|
+
values.push(...decoder.finish());
|
|
185
|
+
expect(values).toEqual([{ text: "撤回并重试" }]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("末行没有换行收尾时在 finish 里交付,空行忽略", () => {
|
|
189
|
+
const decoder = new DesktopStreamBodyDecoder();
|
|
190
|
+
expect(decoder.push(Buffer.from('\n{"endpoint":"/x"}', "utf8"))).toEqual([]);
|
|
191
|
+
expect(decoder.finish()).toEqual([{ endpoint: "/x" }]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("非法 JSON 报错(宿主据此回 400)", () => {
|
|
195
|
+
const decoder = new DesktopStreamBodyDecoder();
|
|
196
|
+
expect(() => decoder.push(Buffer.from("not json\n", "utf8"))).toThrow();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("IPC 契约", () => {
|
|
201
|
+
it("只接受当前协议版本的 ready 与文本 fatal", () => {
|
|
202
|
+
expect(
|
|
203
|
+
isDesktopHostEvent({ type: "ready", protocolVersion: DESKTOP_HOST_PROTOCOL_VERSION }),
|
|
204
|
+
).toBe(true);
|
|
205
|
+
expect(isDesktopHostEvent({ type: "ready", protocolVersion: 99 })).toBe(false);
|
|
206
|
+
expect(isDesktopHostEvent({ type: "fatal", message: "x" })).toBe(true);
|
|
207
|
+
expect(
|
|
208
|
+
isDesktopHostEvent({ type: "fatal", message: "x", diagnostic: "Error: x\n at a" }),
|
|
209
|
+
).toBe(true);
|
|
210
|
+
expect(isDesktopHostEvent({ type: "fatal", message: "x", diagnostic: 7 })).toBe(false);
|
|
211
|
+
expect(isDesktopHostEvent({ type: "fatal" })).toBe(false);
|
|
212
|
+
expect(isDesktopHostEvent("ready")).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("调试端点事件带可选 url 与原因", () => {
|
|
216
|
+
expect(isDesktopHostEvent({ type: "inspected", url: "ws://127.0.0.1:9229/uuid" })).toBe(true);
|
|
217
|
+
expect(isDesktopHostEvent({ type: "inspected", url: null })).toBe(true);
|
|
218
|
+
expect(isDesktopHostEvent({ type: "inspected", url: null, message: "listen failed" })).toBe(
|
|
219
|
+
true,
|
|
220
|
+
);
|
|
221
|
+
expect(isDesktopHostEvent({ type: "inspected" })).toBe(false);
|
|
222
|
+
expect(isDesktopHostEvent({ type: "inspected", url: 9229 })).toBe(false);
|
|
223
|
+
expect(isDesktopHostEvent({ type: "inspected", url: null, message: 7 })).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("只接受 shutdown 与调试端点命令", () => {
|
|
227
|
+
expect(isDesktopHostCommand({ type: "shutdown" })).toBe(true);
|
|
228
|
+
expect(isDesktopHostCommand({ type: "inspect", port: 0 })).toBe(true);
|
|
229
|
+
expect(isDesktopHostCommand({ type: "inspect", port: 65_535 })).toBe(true);
|
|
230
|
+
expect(isDesktopHostCommand({ type: "inspect-off" })).toBe(true);
|
|
231
|
+
expect(isDesktopHostCommand({ type: "update-tasks" })).toBe(false);
|
|
232
|
+
expect(isDesktopHostCommand({ type: "inspect" })).toBe(false);
|
|
233
|
+
expect(isDesktopHostCommand({ type: "inspect", port: -1 })).toBe(false);
|
|
234
|
+
expect(isDesktopHostCommand({ type: "inspect", port: 65_536 })).toBe(false);
|
|
235
|
+
expect(isDesktopHostCommand({ type: "inspect", port: 9229.5 })).toBe(false);
|
|
236
|
+
expect(isDesktopHostCommand(null)).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// 桌面形态的 **IPC 面**:只剩两条——"向壳发事件"(`ready` / `fatal`)与"收尾"。
|
|
2
|
+
//
|
|
3
|
+
// 以前这里还有一条请求/响应字节管道(FD 3/4):页面的请求经它喂给宿主内的 `webServer`。那条路已退休——
|
|
4
|
+
// 窗口现在从宿主真 webServer 的 origin(`http://127.0.0.1:<port>`)载入,请求直连它本身;管道留下的
|
|
5
|
+
// 唯一后果是"没有 origin",而基于 URL/WS 的东西(settings 的 describe 流、账号的 stream mux、vite 的
|
|
6
|
+
// HMR)拿不到答复。判据与取舍见 `../config/desktop.cordis.patch.yml` 的注释。
|
|
7
|
+
|
|
8
|
+
import type { AppProfileBoot } from "@morlay/dsh-app-boot";
|
|
9
|
+
import { applyInspectorCommand, closeInspectorEndpoint } from "./inspector.ts";
|
|
10
|
+
import { isDesktopHostCommand, type DesktopHostEvent } from "./wire.ts";
|
|
11
|
+
|
|
12
|
+
/** 接上壳那条 IPC 通道要什么。 */
|
|
13
|
+
export interface DesktopIpcOptions {
|
|
14
|
+
/** 装配结果:收尾时取它的 `shutdown`(尚未 settle 也照等,装配失败只当作没有树可停)。 */
|
|
15
|
+
readonly application: Promise<AppProfileBoot>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 接好之后的信道两头。 */
|
|
19
|
+
export interface DesktopIpc {
|
|
20
|
+
/** 向壳发事件(`ready` / `fatal`)。 */
|
|
21
|
+
readonly send: (event: DesktopHostEvent) => void;
|
|
22
|
+
/** 收尾:停树、断开 IPC 通道;幂等。 */
|
|
23
|
+
readonly stop: (exitCode?: number) => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 接上 IPC 面:挂好生命周期(`shutdown` 命令 / 通道断开)。
|
|
28
|
+
* @param options - 装配结果。
|
|
29
|
+
* @returns 发事件与收尾两个入口。
|
|
30
|
+
*/
|
|
31
|
+
export function connectDesktopIpc(options: DesktopIpcOptions): DesktopIpc {
|
|
32
|
+
const { application } = options;
|
|
33
|
+
let stopping: Promise<void> | undefined;
|
|
34
|
+
|
|
35
|
+
const send = (event: DesktopHostEvent): void => {
|
|
36
|
+
if (process.send === undefined || !process.connected) return;
|
|
37
|
+
try {
|
|
38
|
+
process.send(event);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// 父进程并发断开时只吞掉通道已关闭这一种;其余照常抛出。
|
|
41
|
+
if ((error as NodeJS.ErrnoException).code !== "ERR_IPC_CHANNEL_CLOSED") throw error;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const stop = (exitCode = 0): Promise<void> => {
|
|
46
|
+
stopping ??= (async () => {
|
|
47
|
+
closeInspectorEndpoint();
|
|
48
|
+
const running = await application.catch(() => undefined);
|
|
49
|
+
await running?.shutdown.shutdown(exitCode);
|
|
50
|
+
if (process.connected) process.disconnect?.();
|
|
51
|
+
process.exitCode = exitCode;
|
|
52
|
+
})();
|
|
53
|
+
return stopping;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
process.on("message", (message: unknown) => {
|
|
57
|
+
if (!isDesktopHostCommand(message)) return;
|
|
58
|
+
// 调试端点命令先于 shutdown 分派:它们只影响回环上的 Node inspector。
|
|
59
|
+
const inspected = applyInspectorCommand(message);
|
|
60
|
+
if (inspected !== undefined) {
|
|
61
|
+
send(inspected);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (message.type === "shutdown") void stop();
|
|
65
|
+
});
|
|
66
|
+
process.once("disconnect", () => {
|
|
67
|
+
void stop();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return { send, stop };
|
|
71
|
+
}
|
package/src/fatal.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 出错时给壳的那条事件的形状:`message` 给壳的 dialog 用;`diagnostic` 是完整 `inspect`——stderr
|
|
2
|
+
// 字节与这条 IPC 谁先到不确定,所以诊断跟着 IPC 一起走。两处用到(IPC 面与入口的顶层兜底),
|
|
3
|
+
// 放这里而不是任何一边。
|
|
4
|
+
|
|
5
|
+
import { inspect } from "node:util";
|
|
6
|
+
import type { DesktopHostEvent } from "./wire.ts";
|
|
7
|
+
|
|
8
|
+
/** 诊断上限:壳把它写进日志,再长也没有额外价值。 */
|
|
9
|
+
const MAX_FATAL_DIAGNOSTIC_CHARS = 64 * 1024;
|
|
10
|
+
|
|
11
|
+
/** 任何抛出来的东西 → 一行消息。 */
|
|
12
|
+
export function errorMessage(error: unknown): string {
|
|
13
|
+
return error instanceof Error ? error.message : String(error);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 造一条 `fatal` 事件。
|
|
18
|
+
* @param error - 触发退出的错误(未必是 `Error`)。
|
|
19
|
+
* @returns 带完整诊断的 fatal 事件。
|
|
20
|
+
*/
|
|
21
|
+
export function fatalEvent(error: unknown): DesktopHostEvent {
|
|
22
|
+
return {
|
|
23
|
+
type: "fatal",
|
|
24
|
+
message: errorMessage(error),
|
|
25
|
+
diagnostic: inspect(error, { depth: 4, maxArrayLength: 50 }).slice(
|
|
26
|
+
0,
|
|
27
|
+
MAX_FATAL_DIAGNOSTIC_CHARS,
|
|
28
|
+
),
|
|
29
|
+
};
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// 桌面宿主进程的**入口**:解析参数 → 装配 profile → 接线。就这三步——协议在
|
|
2
|
+
// [`./desktop-ipc.ts`](./desktop-ipc.ts)、装配在 [`@morlay/dsh-app-boot`](../../boot/dsh-app-boot/README.md)、传输接管在
|
|
3
|
+
// [`./transport.ts`](./transport.ts);`webServer` 服务来自上游 `@deepseek-ai/dsh-host-webserver`。
|
|
4
|
+
//
|
|
5
|
+
// argv 与 web 入口**同一套**(见 [`./invocation.ts`](./invocation.ts)):
|
|
6
|
+
// `--profile-dir <dir> --runtime-dir <dir> [--patch <file>]... [--pnpm <path> --node-bin <dir>] [-- <内层参数>]`;
|
|
7
|
+
// 另加两条通道:请求/响应字节管道(FD 3/4)与 Node IPC(`ready` / `fatal` / `shutdown`)。
|
|
8
|
+
// 装配层完全由 profile 的 `dsh.profile.bundles` 决定:打包形态的 profile 由工具按官方形态种出
|
|
9
|
+
// (见 `../.agents/designs/20260929-桌面host的运行时面与依赖边界.md`);dev 形态由启动器把 app 目录当
|
|
10
|
+
// profile root 传进来(同一份清单,见 `../../dshdev/.agents/designs/20261010-app目录即profile-root.md`)。
|
|
11
|
+
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
14
|
+
import { bootAppProfile } from "@morlay/dsh-app-boot";
|
|
15
|
+
import { connectDesktopIpc } from "./desktop-ipc.ts";
|
|
16
|
+
import { fatalEvent } from "./fatal.ts";
|
|
17
|
+
import { parseHostInvocation } from "./invocation.ts";
|
|
18
|
+
import { installPreloadSource } from "./preload-source.ts";
|
|
19
|
+
import {
|
|
20
|
+
DESKTOP_STREAM_PATH,
|
|
21
|
+
desktopStreamStats,
|
|
22
|
+
installDesktopTransport,
|
|
23
|
+
takeOverDesktopAuthentication,
|
|
24
|
+
} from "./transport.ts";
|
|
25
|
+
import { DESKTOP_HOST_PROTOCOL_VERSION, type DesktopHostEvent } from "./wire.ts";
|
|
26
|
+
import type {} from "@deepseek-ai/dsh-api-gateway";
|
|
27
|
+
import type {} from "@deepseek-ai/dsh-client-connection";
|
|
28
|
+
import type {} from "@deepseek-ai/dsh-host-webserver";
|
|
29
|
+
|
|
30
|
+
// 桌面形态自己的 overlay:产品遥测、账号 UI,以及 web-runtime 的桌面配置。
|
|
31
|
+
const DESKTOP_PATCH = fileURLToPath(new URL("../config/desktop.cordis.patch.yml", import.meta.url));
|
|
32
|
+
|
|
33
|
+
// 按需的内存/流上报:默认关,`DSH_MEMORY_REPORT=1` 时每 5 秒一行。用来把「宿主内存一直在涨」
|
|
34
|
+
// 落到具体哪条流、多少字节上(heapUsed 涨 = 转译/业务对象,external 涨 = 缓冲或原生件)。
|
|
35
|
+
function reportMemoryWhenAsked(ctx: Context): void {
|
|
36
|
+
if (process.env.DSH_MEMORY_REPORT !== "1") return;
|
|
37
|
+
const megabytes = (value: number): string => (value / 1024 / 1024).toFixed(1);
|
|
38
|
+
const timer = setInterval(() => {
|
|
39
|
+
const memory = process.memoryUsage();
|
|
40
|
+
ctx.logger.info(
|
|
41
|
+
`[dsh-desktop] memory rss=${megabytes(memory.rss)}MB heapUsed=${megabytes(memory.heapUsed)}MB ` +
|
|
42
|
+
`external=${megabytes(memory.external)}MB | streams active=${String(desktopStreamStats.active)} ` +
|
|
43
|
+
`opened=${String(desktopStreamStats.opened)} failed=${String(desktopStreamStats.failed)} ` +
|
|
44
|
+
`items=${String(desktopStreamStats.items)} bytes=${megabytes(desktopStreamStats.bytes)}MB`,
|
|
45
|
+
);
|
|
46
|
+
}, 5000);
|
|
47
|
+
timer.unref();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 插件页的包操作通道:随包 node 跑随包 pnpm;`ELECTRON_RUN_AS_NODE` 让 Electron 把自己当 node 用。
|
|
51
|
+
function packageManagerOf(pnpmEntry: string, nodeBin: string | undefined) {
|
|
52
|
+
return {
|
|
53
|
+
command: process.execPath,
|
|
54
|
+
args: ["--expose-internals", pnpmEntry],
|
|
55
|
+
env: {
|
|
56
|
+
ELECTRON_RUN_AS_NODE: "1",
|
|
57
|
+
DSH_DESKTOP_NODE_EXECUTABLE: process.execPath,
|
|
58
|
+
PATH: `${nodeBin ?? ""}${nodeBin === undefined ? "" : ":"}${process.env.PATH ?? ""}`,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function main(): Promise<void> {
|
|
64
|
+
const invocation = parseHostInvocation("dsh desktop", process.argv.slice(2));
|
|
65
|
+
if (process.send === undefined)
|
|
66
|
+
throw new Error(
|
|
67
|
+
"dsh desktop: expected a Node IPC channel and the byte pipes on fd 3/4 (the shell owns them)",
|
|
68
|
+
);
|
|
69
|
+
const { profileDir, patches, pnpmEntry, nodeBin, inner } = invocation;
|
|
70
|
+
|
|
71
|
+
// 装配走 [`./app-boot.ts`](./app-boot.ts) 那条路(与 web 形态同一份实现):层由 profile 的
|
|
72
|
+
// `dsh.profile.bundles` 决定(各 bundle 自己的 patch 由 loader 按 `dsh.bundle.patch` 读),
|
|
73
|
+
// 这里只按顺序补两层 overlay:调用方给的(壳)+ 桌面形态自己的。
|
|
74
|
+
const application = bootAppProfile({
|
|
75
|
+
profileDir,
|
|
76
|
+
profile: "desktop",
|
|
77
|
+
patchFiles: [...patches, DESKTOP_PATCH],
|
|
78
|
+
// 桌面壳自己管窗口:不让 app 行去开浏览器;`--` 之后的内层参数排在其后。
|
|
79
|
+
args: ["--no-open", ...inner],
|
|
80
|
+
...(pnpmEntry === undefined ? {} : { packageManager: packageManagerOf(pnpmEntry, nodeBin) }),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { ctx } = await application;
|
|
84
|
+
takeOverDesktopAuthentication(ctx);
|
|
85
|
+
installDesktopTransport(ctx);
|
|
86
|
+
reportMemoryWhenAsked(ctx);
|
|
87
|
+
// 调试窗口的前端产物:桌面档自己托管,不从上游 inspector 行取(那条路由与它的 Worker 绑在一起)。
|
|
88
|
+
// preload 的源码由这里现场打成 CJS(sandboxed preload 只能 CJS),壳在窗口创建前取走——装配目录里
|
|
89
|
+
// 因此不再需要一份预产的 `dist/`。
|
|
90
|
+
const shellRoot = process.env["DSH_DESKTOP_SHELL_ROOT"];
|
|
91
|
+
if (shellRoot !== undefined && shellRoot !== "") {
|
|
92
|
+
installPreloadSource(ctx, { shellRoot });
|
|
93
|
+
}
|
|
94
|
+
// 真 webServer(上游 `@deepseek-ai/dsh-host-webserver`):只用它的 `port` 报 origin 给壳。
|
|
95
|
+
const webServer = ctx.get("webServer") as unknown as { readonly port: number };
|
|
96
|
+
const ipc = connectDesktopIpc({ application });
|
|
97
|
+
|
|
98
|
+
const gateway = ctx.get("typertGateway");
|
|
99
|
+
ctx.logger.info("transport ready %o", {
|
|
100
|
+
// 结构化字段:键名 `.` 分层、不带空格;值作为参数传(不走 `key=value` 文本)。
|
|
101
|
+
webServer: webServer.constructor.name,
|
|
102
|
+
gateway: gateway === undefined ? "missing" : "present",
|
|
103
|
+
streamRoute: DESKTOP_STREAM_PATH,
|
|
104
|
+
});
|
|
105
|
+
ipc.send({
|
|
106
|
+
type: "ready",
|
|
107
|
+
protocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
|
|
108
|
+
// 真 webServer 的 origin:壳用它载入窗口。基于 URL/WS 的东西(settings 的 describe 流、账号的
|
|
109
|
+
// stream mux、vite 的 HMR)都要求窗口的来源是它——以前没有 origin(请求走字节管道喂进来),那些
|
|
110
|
+
// 长连接拿不到答复,表现为"设置页永远不可用、连请求都看不到"。
|
|
111
|
+
origin: `http://127.0.0.1:${String(webServer.port)}`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (import.meta.main) {
|
|
116
|
+
// 顶层兜底**只能用 console**:走到这里时树可能还没起(`ctx.logger` 不存在)或已经拆了。
|
|
117
|
+
main().catch((error: unknown) => {
|
|
118
|
+
if (process.send !== undefined && process.connected)
|
|
119
|
+
process.send(fatalEvent(error) satisfies DesktopHostEvent, (sendError) => {
|
|
120
|
+
if (sendError !== null) console.error(sendError);
|
|
121
|
+
});
|
|
122
|
+
console.error(error);
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
if (process.connected) process.disconnect?.();
|
|
125
|
+
});
|
|
126
|
+
}
|
package/src/inspector.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// 桌面档的后端调试端点:host 运行时开关 Node 自己的 inspector,只监听回环。
|
|
2
|
+
// 与 `DSH_DESKTOP_HOST_INSPECT_PORT` 那条启动参数用的是同一套机制,区别是这条不需要重启进程。
|
|
3
|
+
|
|
4
|
+
import inspector from "node:inspector";
|
|
5
|
+
import type { DesktopHostCommand, DesktopHostEvent } from "./wire.ts";
|
|
6
|
+
|
|
7
|
+
type InspectorEvent = Extract<DesktopHostEvent, { type: "inspected" }>;
|
|
8
|
+
|
|
9
|
+
function errorText(error: unknown): string {
|
|
10
|
+
return error instanceof Error ? error.message : String(error);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 打开回环上的 Node inspector,返回它的 WebSocket 端点(`port` 为 0 时用随机端口)。 */
|
|
14
|
+
export function openInspectorEndpoint(port: number): string {
|
|
15
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
16
|
+
throw new Error(`dsh desktop: inspector port ${String(port)} is out of range`);
|
|
17
|
+
// 已经有端点就复用它:dev 形态的 `--inspect=127.0.0.1:<port>` 启动参数已经占着一个,
|
|
18
|
+
// 关掉再开只会把那个端口换成新的。
|
|
19
|
+
const existing = inspector.url();
|
|
20
|
+
if (existing !== undefined) return existing;
|
|
21
|
+
inspector.open(port, "127.0.0.1");
|
|
22
|
+
const url = inspector.url();
|
|
23
|
+
if (url === undefined) throw new Error("dsh desktop: the inspector endpoint did not open");
|
|
24
|
+
return url;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 关掉调试端点;没开时什么都不做。 */
|
|
28
|
+
export function closeInspectorEndpoint(): void {
|
|
29
|
+
inspector.close();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 处理一条调试端点命令,返回要回报给壳的事件;不是调试命令时返回 undefined。 */
|
|
33
|
+
export function applyInspectorCommand(command: DesktopHostCommand): InspectorEvent | undefined {
|
|
34
|
+
if (command.type === "inspect-off") {
|
|
35
|
+
closeInspectorEndpoint();
|
|
36
|
+
return { type: "inspected", url: null };
|
|
37
|
+
}
|
|
38
|
+
if (command.type !== "inspect") return undefined;
|
|
39
|
+
try {
|
|
40
|
+
return { type: "inspected", url: openInspectorEndpoint(command.port) };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return { type: "inspected", url: null, message: errorText(error) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// 两个宿主入口(桌面 [`./index.ts`](./index.ts) 与 web [`./web.ts`](./web.ts))**共用**的启动参数。
|
|
2
|
+
//
|
|
3
|
+
// 为什么非要把这套 flag 抽出来:这两个入口拿到的输入完全同形——profile root、工具链根、形态自己的
|
|
4
|
+
// overlay、应用包通道、内层参数。以前桌面用位置参数(`argv[2]`/`argv[3]`)、web 用 `--profile-dir`
|
|
5
|
+
// 风格 flag,各自解析、各自组选项,"挂哪一层、锚点指哪"很容易只在一边改对,于是出现"dev 正常、打包
|
|
6
|
+
// 不对"这种只能靠猜的差异。现在只有这一处解析,两个入口的输入同形。
|
|
7
|
+
|
|
8
|
+
/** One host process invocation: what the process was asked to boot. */
|
|
9
|
+
export interface HostInvocation {
|
|
10
|
+
/** profile root:dev 是 app 目录本身,打包是 `$DSH_HOME/profiles/<name>`。 */
|
|
11
|
+
readonly profileDir: string;
|
|
12
|
+
/** 工具链根(`node` / `pnpm` / loader):不是依赖面。 */
|
|
13
|
+
readonly runtimeDir: string;
|
|
14
|
+
/** 形态自己的 overlay patch 文件,按 argv 顺序排在 bundle 层与 profile 自己的 patch 之后。 */
|
|
15
|
+
readonly patches: string[];
|
|
16
|
+
/** 应用包操作通道:随包 pnpm 的入口(缺省时插件页回退到进程 PATH)。 */
|
|
17
|
+
readonly pnpmEntry?: string;
|
|
18
|
+
/** 包通道要用的 node 可执行文件(打包形态的随包 node,与 `pnpm` 同属工具链)。 */
|
|
19
|
+
readonly nodeBin?: string;
|
|
20
|
+
/** `--` 之后的内层参数,原样交给树里 app 插件的 flag family。 */
|
|
21
|
+
readonly inner: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 解析宿主入口的参数。
|
|
26
|
+
* @param binName - 报错时前缀用的入口名(`dsh desktop` / `dsh web`)。
|
|
27
|
+
* @param argv - `process.argv.slice(2)`。
|
|
28
|
+
* @returns 两形态同形的启动参数。
|
|
29
|
+
* @throws 缺两格锚点、该带值的参数缺值、或参数名不认识时。
|
|
30
|
+
*/
|
|
31
|
+
export function parseHostInvocation(binName: string, argv: readonly string[]): HostInvocation {
|
|
32
|
+
let profileDir: string | undefined;
|
|
33
|
+
let runtimeDir: string | undefined;
|
|
34
|
+
let pnpmEntry: string | undefined;
|
|
35
|
+
let nodeBin: string | undefined;
|
|
36
|
+
const patches: string[] = [];
|
|
37
|
+
const inner: string[] = [];
|
|
38
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
39
|
+
const argument = argv[index];
|
|
40
|
+
if (argument === "--") {
|
|
41
|
+
inner.push(...argv.slice(index + 1));
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
const value = (): string => {
|
|
45
|
+
const next = argv[index + 1];
|
|
46
|
+
if (next === undefined) throw new Error(`${binName}: ${String(argument)} needs a value`);
|
|
47
|
+
index += 1;
|
|
48
|
+
return next;
|
|
49
|
+
};
|
|
50
|
+
if (argument === "--profile-dir") profileDir = value();
|
|
51
|
+
else if (argument === "--runtime-dir") runtimeDir = value();
|
|
52
|
+
else if (argument === "--patch") patches.push(value());
|
|
53
|
+
else if (argument === "--pnpm") pnpmEntry = value();
|
|
54
|
+
else if (argument === "--node-bin") nodeBin = value();
|
|
55
|
+
else throw new Error(`${binName}: unknown argument ${JSON.stringify(String(argument))}`);
|
|
56
|
+
}
|
|
57
|
+
if (profileDir === undefined || runtimeDir === undefined) {
|
|
58
|
+
throw new Error(`${binName}: expected --profile-dir <dir> and --runtime-dir <dir>`);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
profileDir,
|
|
62
|
+
runtimeDir,
|
|
63
|
+
patches,
|
|
64
|
+
...(pnpmEntry === undefined ? {} : { pnpmEntry }),
|
|
65
|
+
...(nodeBin === undefined ? {} : { nodeBin }),
|
|
66
|
+
inner,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/paths.ts
ADDED