@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 CHANGED
@@ -2,14 +2,28 @@
2
2
 
3
3
  桌面部署里那个 **host 进程**:按 `desktop` profile 把 Web 应用启起来,然后**在进程内接管 `webServer`**——不监听任何端口,Electron 壳把该应用自己的协议(`<app name>://app/*`)的请求经字节管道(FD 3/4)喂进来。
4
4
 
5
- 上游 `apps/desktop-host`(`@deepseek-ai/dsh-desktop-host`)是 `private: true` 的应用,外部装不到,所以这里是它的**变体包**:argv / IPC 契约、`lib/index.js` 入口路径照旧。
5
+ 上游 `apps/desktop-host`(`@deepseek-ai/dsh-desktop-host`)是 `private: true` 的应用,外部装不到,所以这里是它的**变体包**:IPC 契约照旧,argv 与本仓库的 web 入口统一成一套 flag(见下)。
6
6
 
7
7
  ## 用法
8
8
 
9
- 被 [`@morlay/dsh-desktopify`](../dsh-desktopify/README.md) 使用,不单独跑:
9
+ 被 [`@local/dshdev`](../../../devpackages/dshdev/README.md) 使用,不单独跑:
10
10
 
11
- - `dev` / `bundle` 把本包落位到部署的 `<runtime>/node_modules/@morlay/dsh-desktop-host`(`dev` 整份复制源码树,`bundle` 按 manifest `files` 复制),四样必须都在——`lib/index.js`(启动入口)、`lib/webserver.js`(桌面 patch 行按 `../lib/webserver.js` 加载)、`lib/wire.js`、`config/desktop.cordis.patch.yml`(入口按它作 `patchFiles` 读);
12
- - 壳按 `lib/index.js` 启动它(stdio 五元组:FD 3/4 是管道,FD 5 是 Node IPC),argv 为 `[entry, runtimeDir, projectDir, pnpmEntry?, nodeBin?]`;
13
- - IPC:`ready { protocolVersion }` / `fatal { message, diagnostic }`,另外收 `shutdown`。
11
+ - `dev` / `bundle` 把本包落位到部署的 `<runtime>/node_modules/@morlay/dsh-desktop-host`(`dev` 从工作区解析同一份包,`bundle` 按 manifest `files` 复制),三样必须都在——`src/index.ts`(启动入口,壳按 node 解析取**源码**,由 TS loader 转译)、当时那个无端口替身(已删)(桌面 patch 行按 当时那个无端口替身(已删) 加载)、`config/desktop.cordis.patch.yml`(入口按它作 `patchFiles` 读);
12
+ - 壳启动它(stdio:stdout/stderr 落盘 + FD 5 是 Node IPC),argv 与 web 入口**同一套**
13
+ (解析只有一处,见 [`src/invocation.ts`](./src/invocation.ts)):
14
14
 
15
- 运行时做的事(profile 装配、`webServer` 替身、认证接管、客户端 transport 与账号面)、出口面与依赖边界见[设计 桌面host的运行时面与依赖边界](./.agents/designs/20260929-桌面host的运行时面与依赖边界.md);载波形态的取舍见[设计 桌面无端口传输与窗口对齐](../dsh-desktopify/.agents/designs/20260920-桌面无端口传输与窗口对齐.md)。
15
+ ```
16
+ <entry> --profile-dir <dir> --runtime-dir <dir> [--patch <file>]… [--pnpm <path> --node-bin <dir>] [-- <内层参数>]
17
+ ```
18
+
19
+ `--profile-dir` 是 profile root(打包形态是种子种出的 `$DSH_HOME/profiles/<name>`,dev 形态是 app 目录本身);
20
+
21
+ - **入口只做三件事**:[`src/index.ts`](./src/index.ts) 解析参数 → 装配 → 接线;装配在
22
+ [`@morlay/dsh-app-boot`](../../boot/dsh-app-boot/README.md)(desktop 与 web 共用,层完全由 profile 的
23
+ `dsh.profile.bundles` 决定),管道与请求分发在 [`src/desktop-ipc.ts`](./src/desktop-ipc.ts),传输接管在
24
+ [`src/transport.ts`](./src/transport.ts);
25
+ - `--web`(dev 的 web 形态)走 [`src/web.ts`](./src/web.ts):同一个 `bootAppProfile`,只是 profile 名换成
26
+ `web` 并多一个端口契约,见[设计 dev 形态的 profile root 就是 app 目录](../../../devpackages/dshdev/.agents/designs/20261014-数据与工作目录的-layout.md);
27
+ - IPC:`ready { protocolVersion, wsPort }` / `fatal { message, diagnostic }`,另外收 `shutdown`。
28
+
29
+ 运行时做的事(profile 装配、`webServer` 替身、认证接管、客户端 transport 与账号面)、出口面与依赖边界见[设计 桌面host的运行时面与依赖边界](./.agents/designs/20260929-桌面host的运行时面与依赖边界.md);载波形态的取舍见[设计 桌面运行形态与窗口对齐](.agents/designs/20260920-桌面运行形态与窗口对齐.md)。
@@ -1,20 +1,11 @@
1
- # 桌面形态的传输接管:上游 `webserver` 行在激活时 listen,桌面没有网络入口,
2
- # 这里禁用它并插入宿主内的无端口替身(同服务名 `webServer`、同一份路由面,
3
- # 请求由字节管道经 dispatch 喂进来)。其余行沿用 web-app bundle。
1
+ # 桌面形态**不再替换** `webserver`:以前这里禁用它、插一个"无端口替身",让请求走字节管道喂进来——
2
+ # 那样没有 origin(不是真的 `http://host:port`),而基于 URL/WS 的东西(settings 的 `describe` 流、
3
+ # 账号的 stream mux、vite 的 HMR)拿不到答复,表现为"设置页永远不可用、连请求都看不到"。
4
+ # 现在回到上游的做法:**照常 listen(随机端口)**,壳用它的 origin 载入窗口。
4
5
  #
5
6
  # `web-runtime` 的四项一并写全:patch 整块替换 config,没有端口可打印、也没有
6
7
  # 浏览器可交接;`surfaceContext` 关掉,免得提示段带一个并不存在的 URL。
7
8
 
8
- - id: webserver
9
- disabled: true
10
-
11
- - insert:
12
- - id: portless-webserver
13
- name: "../lib/webserver.js"
14
- config:
15
- host: "127.0.0.1"
16
- port: 0
17
-
18
9
  - id: web-runtime
19
10
  config:
20
11
  openBrowser: false
@@ -22,32 +13,43 @@
22
13
  surfaceContext: false
23
14
  trustedHosts: []
24
15
 
25
- # 桌面档不含登录,账号面整条停掉。DeepSeek 账号登录是「系统浏览器授权 + 回环地址回调」:
26
- # 上游 `dsh-deepseek-account-platform` 在 webServer 上注册 `/oauth/callback`,授权页要回到
27
- # `http://localhost:<端口>/oauth/callback`——而本部署的 webServer 没有端口(上面的
28
- # portless-webserver),回调没有入口;client 半算出的回调来源是页面的自定义协议来源,
29
- # 连协议校验都过不了。装着的那几个面点进去只会撞死路,于是按 id 停掉:
30
- #
31
- # - `deepseek-account`:登录实现(凭据记录、事务面与浏览器流程的宿主一半);
32
- # - `account-controller`:账号事务的 Remote 名册,硬注入 `deepseekAccount`,登录实现停了
33
- # 它只会等一个不会出现的服务;
34
- # - `llm-deepseek-account`:账号 token 独占的模型路由,token 只可能来自上面那条登录,
35
- # 留着则是一条永远 `ACCOUNT_SIGN_IN_REQUIRED` 的死路由;
36
- # - `ui-settings-account`:账号的 client 面(设置页的账号页、侧栏账号菜单、桌面首次引导
37
- # 覆盖层、充值页宿主),并且是 `ui-settings-account` 这个 settings namespace 的 owner。
38
- #
39
- # 留下的仍是本部署的模型路径:`credentials`(通用凭据服务)与 `llm-deepseek`(官方 API key
40
- # 路由,不是登录)都不动——模型走 ollama provider,key 由 `credentials` 解析
41
- # `apiKeyEnv: OLLAMA_API_KEY`(profile 层给的)。
42
-
43
- - id: deepseek-account
16
+ # **端口要随机**:`web-app` bundle 给 `webserver` 的默认是 `webStartup.port ?? 3080`,而桌面形态没有
17
+ # `--port` 参数,于是落到 3080——同时开两个实例、或本机上别的工具占了它,第二次就起不来。桌面没有
18
+ # "可预期的地址"要对外(窗口从 origin 载入,地址由宿主自己报),所以向系统要一个:`0`(上游的
19
+ # `webserver` 明确支持:"zero requests an OS-assigned port")。
20
+ - id: webserver
21
+ config:
22
+ # `host` 与 `port` 是必填(patch 整块替换 config,写一半就是 `$.host missing required value`)。
23
+ # 只绑回环:桌面不需要对外服务。
24
+ host: "127.0.0.1"
25
+ port: 0
26
+
27
+ # 桌面档的产品遥测面:上游那两行只在 profile 名为 `desktop` 时激活,靠构建期环境变量 `DSH_CLIENT_VERSION`
28
+ # 填 `serviceVersion`(必填)。自研壳不传那个变量,于是遥测行配置校验失败、`product-analytics` 行 pending,
29
+ # 页面启动时 `productAnalytics/watchPolicy` 那条流拿 502,前端又在非 JSON 响应上抛未捕获错误。
30
+ # 本地桌面部署没有上报对象(endpoint 是官方 collector),两行一起停。
31
+ # 决策见 [ADR 桌面档无产品遥测](../.agents/adrs/20261008-桌面档无产品遥测.md)。
32
+
33
+ - id: desktop-product-telemetry
44
34
  disabled: true
45
35
 
46
- - id: llm-deepseek-account
36
+ - id: product-analytics
47
37
  disabled: true
48
38
 
49
- - id: account-controller
50
- disabled: true
39
+ # **本部署不要账号 UI**:上游 `ui-settings-account` 那一行渲染设置页的账号页、侧栏账号菜单与首次引导层。
40
+ # 它同时还是那个 **settings namespace 的 owner**——光停它,前端镜面就拿不到名册(`describeFace.view
41
+ # === undefined`)→ 直接 failLoad,整个设置页判 "settings are unavailable in this browser",连
42
+ # `/api/settings` 都不请求。所以停它的同时,由下面那行**顶上同一个注册**(不渲染任何界面)。
43
+ #
44
+ # **为什么停用必须写在这里**:实测,写在 bundle 层的停用会被后来应用的层盖回去;这份 overlay 由宿主追加在
45
+ # `patchFiles` 末尾(`src/index.ts:80`),是最后应用的一层。
51
46
 
52
47
  - id: ui-settings-account
53
48
  disabled: true
49
+
50
+ # 顶替上面那行不可省的那件事:注册 settings namespace(同样的 `settings.configure({ auto: false })`,
51
+ # 不渲染账号页/侧栏菜单/引导层)。实现见 [`../src/settings-namespace.ts`](../src/settings-namespace.ts)。
52
+
53
+ - insert:
54
+ - id: settings-namespace
55
+ name: "../src/settings-namespace.ts"
@@ -0,0 +1 @@
1
+ export {}
package/dist/index.js ADDED
@@ -0,0 +1,430 @@
1
+ import { installPreloadSource } from "./preload-source.js";
2
+ import { DESKTOP_STREAM_PATH, DesktopStreamBodyDecoder, isDesktopHostCommand } from "./wire.js";
3
+ import { fileURLToPath } from "node:url";
4
+ import { bootAppProfile } from "@morlay/dsh-app-boot";
5
+ import inspector from "node:inspector";
6
+ import { inspect } from "node:util";
7
+ import { once } from "node:events";
8
+ //#region src/inspector.ts
9
+ function errorText$1(error) {
10
+ return error instanceof Error ? error.message : String(error);
11
+ }
12
+ /** 打开回环上的 Node inspector,返回它的 WebSocket 端点(`port` 为 0 时用随机端口)。 */
13
+ function openInspectorEndpoint(port) {
14
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`dsh desktop: inspector port ${String(port)} is out of range`);
15
+ const existing = inspector.url();
16
+ if (existing !== void 0) return existing;
17
+ inspector.open(port, "127.0.0.1");
18
+ const url = inspector.url();
19
+ if (url === void 0) throw new Error("dsh desktop: the inspector endpoint did not open");
20
+ return url;
21
+ }
22
+ /** 关掉调试端点;没开时什么都不做。 */
23
+ function closeInspectorEndpoint() {
24
+ inspector.close();
25
+ }
26
+ /** 处理一条调试端点命令,返回要回报给壳的事件;不是调试命令时返回 undefined。 */
27
+ function applyInspectorCommand(command) {
28
+ if (command.type === "inspect-off") {
29
+ closeInspectorEndpoint();
30
+ return {
31
+ type: "inspected",
32
+ url: null
33
+ };
34
+ }
35
+ if (command.type !== "inspect") return void 0;
36
+ try {
37
+ return {
38
+ type: "inspected",
39
+ url: openInspectorEndpoint(command.port)
40
+ };
41
+ } catch (error) {
42
+ return {
43
+ type: "inspected",
44
+ url: null,
45
+ message: errorText$1(error)
46
+ };
47
+ }
48
+ }
49
+ //#endregion
50
+ //#region src/desktop-ipc.ts
51
+ /**
52
+ * 接上 IPC 面:挂好生命周期(`shutdown` 命令 / 通道断开)。
53
+ * @param options - 装配结果。
54
+ * @returns 发事件与收尾两个入口。
55
+ */
56
+ function connectDesktopIpc(options) {
57
+ const { application } = options;
58
+ let stopping;
59
+ const send = (event) => {
60
+ if (process.send === void 0 || !process.connected) return;
61
+ try {
62
+ process.send(event);
63
+ } catch (error) {
64
+ if (error.code !== "ERR_IPC_CHANNEL_CLOSED") throw error;
65
+ }
66
+ };
67
+ const stop = (exitCode = 0) => {
68
+ stopping ??= (async () => {
69
+ closeInspectorEndpoint();
70
+ await (await application.catch(() => void 0))?.shutdown.shutdown(exitCode);
71
+ if (process.connected) process.disconnect?.();
72
+ process.exitCode = exitCode;
73
+ })();
74
+ return stopping;
75
+ };
76
+ process.on("message", (message) => {
77
+ if (!isDesktopHostCommand(message)) return;
78
+ const inspected = applyInspectorCommand(message);
79
+ if (inspected !== void 0) {
80
+ send(inspected);
81
+ return;
82
+ }
83
+ if (message.type === "shutdown") stop();
84
+ });
85
+ process.once("disconnect", () => {
86
+ stop();
87
+ });
88
+ return {
89
+ send,
90
+ stop
91
+ };
92
+ }
93
+ //#endregion
94
+ //#region src/fatal.ts
95
+ /** 诊断上限:壳把它写进日志,再长也没有额外价值。 */
96
+ const MAX_FATAL_DIAGNOSTIC_CHARS = 65536;
97
+ /** 任何抛出来的东西 → 一行消息。 */
98
+ function errorMessage(error) {
99
+ return error instanceof Error ? error.message : String(error);
100
+ }
101
+ /**
102
+ * 造一条 `fatal` 事件。
103
+ * @param error - 触发退出的错误(未必是 `Error`)。
104
+ * @returns 带完整诊断的 fatal 事件。
105
+ */
106
+ function fatalEvent(error) {
107
+ return {
108
+ type: "fatal",
109
+ message: errorMessage(error),
110
+ diagnostic: inspect(error, {
111
+ depth: 4,
112
+ maxArrayLength: 50
113
+ }).slice(0, MAX_FATAL_DIAGNOSTIC_CHARS)
114
+ };
115
+ }
116
+ //#endregion
117
+ //#region src/invocation.ts
118
+ /**
119
+ * 解析宿主入口的参数。
120
+ * @param binName - 报错时前缀用的入口名(`dsh desktop` / `dsh web`)。
121
+ * @param argv - `process.argv.slice(2)`。
122
+ * @returns 两形态同形的启动参数。
123
+ * @throws 缺两格锚点、该带值的参数缺值、或参数名不认识时。
124
+ */
125
+ function parseHostInvocation(binName, argv) {
126
+ let profileDir;
127
+ let runtimeDir;
128
+ let pnpmEntry;
129
+ let nodeBin;
130
+ const patches = [];
131
+ const inner = [];
132
+ for (let index = 0; index < argv.length; index += 1) {
133
+ const argument = argv[index];
134
+ if (argument === "--") {
135
+ inner.push(...argv.slice(index + 1));
136
+ break;
137
+ }
138
+ const value = () => {
139
+ const next = argv[index + 1];
140
+ if (next === void 0) throw new Error(`${binName}: ${String(argument)} needs a value`);
141
+ index += 1;
142
+ return next;
143
+ };
144
+ if (argument === "--profile-dir") profileDir = value();
145
+ else if (argument === "--runtime-dir") runtimeDir = value();
146
+ else if (argument === "--patch") patches.push(value());
147
+ else if (argument === "--pnpm") pnpmEntry = value();
148
+ else if (argument === "--node-bin") nodeBin = value();
149
+ else throw new Error(`${binName}: unknown argument ${JSON.stringify(String(argument))}`);
150
+ }
151
+ if (profileDir === void 0 || runtimeDir === void 0) throw new Error(`${binName}: expected --profile-dir <dir> and --runtime-dir <dir>`);
152
+ return {
153
+ profileDir,
154
+ runtimeDir,
155
+ patches,
156
+ ...pnpmEntry === void 0 ? {} : { pnpmEntry },
157
+ ...nodeBin === void 0 ? {} : { nodeBin },
158
+ inner
159
+ };
160
+ }
161
+ //#endregion
162
+ //#region src/transport.ts
163
+ const desktopStreamStats = {
164
+ active: 0,
165
+ opened: 0,
166
+ failed: 0,
167
+ items: 0,
168
+ bytes: 0
169
+ };
170
+ const DESKTOP_TRANSPORT_SCRIPT = `globalThis.__DSH_TRANSPORT__={
171
+ _desktop:true,
172
+ ownsHost:true,
173
+ async *openStream(endpoint,payload,signal,uplink){
174
+ console.info('[dsh-desktop] openStream',endpoint)
175
+ const carrier=globalThis.__DSH_DESKTOP_STREAM__
176
+ if(carrier===undefined){
177
+ console.error('[dsh-desktop] stream carrier missing for',endpoint)
178
+ throw new Error('desktop stream carrier is unavailable')
179
+ }
180
+ const queue=[]
181
+ let buffer=''
182
+ let ended=false
183
+ let failure
184
+ let wake
185
+ const notify=()=>{const pending=wake;wake=undefined;if(pending)pending()}
186
+ const append=(text)=>{
187
+ buffer+=text
188
+ let at
189
+ while((at=buffer.indexOf('\\n'))!==-1){
190
+ const line=buffer.slice(0,at)
191
+ buffer=buffer.slice(at+1)
192
+ if(line!=='')queue.push(JSON.parse(line))
193
+ }
194
+ notify()
195
+ }
196
+ const stream=carrier.open(endpoint,payload,{
197
+ chunk(text){append(text)},
198
+ end(){
199
+ if(buffer!==''){queue.push(JSON.parse(buffer));buffer=''}
200
+ ended=true
201
+ notify()
202
+ },
203
+ fail(message){failure=new Error(message);ended=true;notify()},
204
+ })
205
+ // 收尾只有一处:取消必须结束迭代(不叫醒它,等在这一句上的消费方收不到 end 帧,
206
+ // dispose(await iterator.return)也永不落定——窗口重建会卡在这里),同时取消载体流本身。
207
+ // abort(窗口重建)与消费方提前退出(切会话时上游只 return 迭代器,不发 abort)都走它;
208
+ // 幂等,两条路径都发生也只 cancel 一次。
209
+ let closed=false
210
+ const close=()=>{
211
+ if(closed)return
212
+ closed=true
213
+ stream.cancel()
214
+ ended=true
215
+ notify()
216
+ }
217
+ const onAbort=close
218
+ signal.addEventListener('abort',onAbort,{once:true})
219
+ if(signal.aborted)onAbort()
220
+ // 上行(客户端 → 宿主)是逻辑流的输入项:逐条交给主进程,收尾时结束请求体;
221
+ // 没有 uplink 的流(如 $events)立刻结束请求体,宿主侧不用等一个永不来的 end。
222
+ if(uplink===undefined)stream.end()
223
+ else void (async()=>{
224
+ try{
225
+ for await(const item of uplink){
226
+ // 取消后不再上行:宿主那边这条逻辑流已经作废。
227
+ if(signal.aborted)break
228
+ stream.send(item)
229
+ }
230
+ }catch(error){
231
+ console.error('[dsh-desktop] stream uplink failed for',endpoint,error)
232
+ }finally{
233
+ stream.end()
234
+ }
235
+ })()
236
+ try{
237
+ for(;;){
238
+ while(queue.length>0)yield queue.shift()
239
+ if(failure!==undefined)throw failure
240
+ if(ended)return
241
+ await new Promise(resolve=>{wake=resolve})
242
+ }
243
+ }finally{
244
+ signal.removeEventListener('abort',onAbort)
245
+ close()
246
+ }
247
+ }
248
+ }
249
+ console.info('[dsh-desktop] transport installed',Object.keys(globalThis.__DSH_TRANSPORT__))`;
250
+ function takeOverDesktopAuthentication(ctx) {
251
+ const connection = ctx.get("connection");
252
+ if (connection === void 0) throw new Error("dsh desktop: the composition did not provide the connection service");
253
+ if (typeof connection.requestRejection !== "function" || typeof connection.authorizeIndex !== "function") throw new Error("dsh desktop: the connection service no longer exposes requestRejection / authorizeIndex; desktop authentication takeover needs an update");
254
+ connection.requestRejection = () => void 0;
255
+ connection.authorizeIndex = () => true;
256
+ }
257
+ function isRecord(value) {
258
+ return typeof value === "object" && value !== null && !Array.isArray(value);
259
+ }
260
+ async function* readStreamFrames(request) {
261
+ const decoder = new DesktopStreamBodyDecoder();
262
+ for await (const chunk of request) for (const frame of decoder.push(chunk)) yield frame;
263
+ for (const frame of decoder.finish()) yield frame;
264
+ }
265
+ function parseStreamOpening(value, subject) {
266
+ if (!isRecord(value) || typeof value.endpoint !== "string") throw new Error(`dsh desktop: invalid stream request ${subject}`);
267
+ return {
268
+ endpoint: value.endpoint,
269
+ payload: value.payload ?? { args: {} }
270
+ };
271
+ }
272
+ function reportStreamFailure(subject, detail, log) {
273
+ log.error(`[dsh-desktop] ${subject}: ${errorText(detail)}`);
274
+ }
275
+ function errorText(value) {
276
+ return value instanceof Error ? value.stack ?? value.message : String(value);
277
+ }
278
+ function streamHandler(ctx) {
279
+ return async (request, response) => {
280
+ if (request.method !== "POST") {
281
+ response.writeHead(405);
282
+ response.end();
283
+ return;
284
+ }
285
+ const gateway = ctx.get("typertGateway");
286
+ if (gateway === void 0) {
287
+ reportStreamFailure("stream failed", "typertGateway service is missing", ctx.logger);
288
+ response.writeHead(503);
289
+ response.end("gateway unavailable");
290
+ return;
291
+ }
292
+ const peer = ctx.get("connection")?.browserPeer;
293
+ if (peer === void 0) {
294
+ reportStreamFailure("stream failed", "connection service is missing", ctx.logger);
295
+ response.writeHead(503);
296
+ response.end("connection unavailable");
297
+ return;
298
+ }
299
+ const endpointFromQuery = new URL(request.url ?? "/", "http://127.0.0.1").searchParams.get("endpoint");
300
+ const frames = readStreamFrames(request);
301
+ let opening;
302
+ try {
303
+ const first = await frames.next();
304
+ opening = first.done ? parseStreamOpening(endpointFromQuery === null ? void 0 : { endpoint: endpointFromQuery }, String(request.url)) : parseStreamOpening(first.value, String(request.url));
305
+ } catch (error) {
306
+ reportStreamFailure("stream body is not a valid opener", error, ctx.logger);
307
+ response.writeHead(400);
308
+ response.end("invalid stream request");
309
+ return;
310
+ }
311
+ const endpoint = opening.endpoint;
312
+ const abort = new AbortController();
313
+ const cancel = () => {
314
+ abort.abort();
315
+ };
316
+ request.once("aborted", cancel);
317
+ response.once("close", cancel);
318
+ try {
319
+ ctx.logger.debug(`[dsh-desktop] stream opening ${endpoint}`);
320
+ const values = await gateway.wireStream.open(endpoint, opening.payload, frames, peer, abort.signal);
321
+ ctx.logger.debug(`[dsh-desktop] stream opened ${endpoint}`);
322
+ response.writeHead(200, {
323
+ "content-type": "application/x-ndjson",
324
+ "cache-control": "no-store"
325
+ });
326
+ desktopStreamStats.opened += 1;
327
+ desktopStreamStats.active += 1;
328
+ try {
329
+ for await (const value of values) {
330
+ const line = `${JSON.stringify(value)}\n`;
331
+ desktopStreamStats.items += 1;
332
+ desktopStreamStats.bytes += Buffer.byteLength(line);
333
+ if (!response.write(line)) {
334
+ await Promise.race([once(response, "drain"), once(response, "close")]).catch(() => void 0);
335
+ if (response.destroyed) return;
336
+ }
337
+ }
338
+ response.end();
339
+ } finally {
340
+ desktopStreamStats.active -= 1;
341
+ }
342
+ } catch (error) {
343
+ desktopStreamStats.failed += 1;
344
+ reportStreamFailure(`stream ${endpoint} failed`, error, ctx.logger);
345
+ if (response.headersSent) {
346
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
347
+ return;
348
+ }
349
+ response.writeHead(502);
350
+ response.end(error instanceof Error ? error.message : String(error));
351
+ }
352
+ };
353
+ }
354
+ function installDesktopTransport(ctx) {
355
+ ctx.on("webserver/index-inject", (table) => {
356
+ table.push({
357
+ kind: "script",
358
+ placement: "head",
359
+ text: DESKTOP_TRANSPORT_SCRIPT
360
+ });
361
+ });
362
+ ctx.effect(() => ctx.webServer.register({
363
+ kind: "exact",
364
+ path: DESKTOP_STREAM_PATH,
365
+ handler: streamHandler(ctx)
366
+ }), "dsh-desktop: remote stream route");
367
+ }
368
+ //#endregion
369
+ //#region src/index.ts
370
+ const DESKTOP_PATCH = fileURLToPath(new URL("../config/desktop.cordis.patch.yml", import.meta.url));
371
+ function reportMemoryWhenAsked(ctx) {
372
+ if (process.env.DSH_MEMORY_REPORT !== "1") return;
373
+ const megabytes = (value) => (value / 1024 / 1024).toFixed(1);
374
+ setInterval(() => {
375
+ const memory = process.memoryUsage();
376
+ ctx.logger.info(`[dsh-desktop] memory rss=${megabytes(memory.rss)}MB heapUsed=${megabytes(memory.heapUsed)}MB external=${megabytes(memory.external)}MB | streams active=${String(desktopStreamStats.active)} opened=${String(desktopStreamStats.opened)} failed=${String(desktopStreamStats.failed)} items=${String(desktopStreamStats.items)} bytes=${megabytes(desktopStreamStats.bytes)}MB`);
377
+ }, 5e3).unref();
378
+ }
379
+ function packageManagerOf(pnpmEntry, nodeBin) {
380
+ return {
381
+ command: process.execPath,
382
+ args: ["--expose-internals", pnpmEntry],
383
+ env: {
384
+ ELECTRON_RUN_AS_NODE: "1",
385
+ DSH_DESKTOP_NODE_EXECUTABLE: process.execPath,
386
+ PATH: `${nodeBin ?? ""}${nodeBin === void 0 ? "" : ":"}${process.env.PATH ?? ""}`
387
+ }
388
+ };
389
+ }
390
+ async function main() {
391
+ const invocation = parseHostInvocation("dsh desktop", process.argv.slice(2));
392
+ if (process.send === void 0) throw new Error("dsh desktop: expected a Node IPC channel and the byte pipes on fd 3/4 (the shell owns them)");
393
+ const { profileDir, patches, pnpmEntry, nodeBin, inner } = invocation;
394
+ const application = bootAppProfile({
395
+ profileDir,
396
+ profile: "desktop",
397
+ patchFiles: [...patches, DESKTOP_PATCH],
398
+ args: ["--no-open", ...inner],
399
+ ...pnpmEntry === void 0 ? {} : { packageManager: packageManagerOf(pnpmEntry, nodeBin) }
400
+ });
401
+ const { ctx } = await application;
402
+ takeOverDesktopAuthentication(ctx);
403
+ installDesktopTransport(ctx);
404
+ reportMemoryWhenAsked(ctx);
405
+ const shellRoot = process.env["DSH_DESKTOP_SHELL_ROOT"];
406
+ if (shellRoot !== void 0 && shellRoot !== "") installPreloadSource(ctx, { shellRoot });
407
+ const webServer = ctx.get("webServer");
408
+ const ipc = connectDesktopIpc({ application });
409
+ const gateway = ctx.get("typertGateway");
410
+ ctx.logger.info("transport ready %o", {
411
+ webServer: webServer.constructor.name,
412
+ gateway: gateway === void 0 ? "missing" : "present",
413
+ streamRoute: DESKTOP_STREAM_PATH
414
+ });
415
+ ipc.send({
416
+ type: "ready",
417
+ protocolVersion: 1,
418
+ origin: `http://127.0.0.1:${String(webServer.port)}`
419
+ });
420
+ }
421
+ if (import.meta.main) main().catch((error) => {
422
+ if (process.send !== void 0 && process.connected) process.send(fatalEvent(error), (sendError) => {
423
+ if (sendError !== null) console.error(sendError);
424
+ });
425
+ console.error(error);
426
+ process.exitCode = 1;
427
+ if (process.connected) process.disconnect?.();
428
+ });
429
+ //#endregion
430
+ export {};
@@ -0,0 +1,4 @@
1
+ //#region src/paths.d.ts
2
+ /** 应用 origin 下托管调试窗口前端产物的前缀。 */
3
+ export declare const DEVTOOLS_ASSETS_PREFIX = "/node-devtools";
4
+ //#endregion
package/dist/paths.js ADDED
@@ -0,0 +1,5 @@
1
+ //#region src/paths.ts
2
+ /** 应用 origin 下托管调试窗口前端产物的前缀。 */
3
+ const DEVTOOLS_ASSETS_PREFIX = "/node-devtools";
4
+ //#endregion
5
+ export { DEVTOOLS_ASSETS_PREFIX };
@@ -0,0 +1,29 @@
1
+ import { Context } from "@deepseek-ai/cordis";
2
+ //#region src/preload-source.d.ts
3
+ /** 壳取 preload 的路径(`GET` 它得到可执行的 CJS 字节)。 */
4
+ export declare const PRELOAD_SOURCE_PATH = "/@dsh/preload.cjs";
5
+ export interface PreloadSourceOptions {
6
+ /** 壳包所在的目录(app 目录):preload 的源码在它的 `src/` 下。 */
7
+ readonly shellRoot: string;
8
+ }
9
+ /**
10
+ * 把 preload 打包成单文件 CJS。
11
+ *
12
+ * @param options - 见 {@link PreloadSourceOptions}。
13
+ * @returns CJS 源码。
14
+ */
15
+ export declare function bundlePreload(options: PreloadSourceOptions): Promise<string>;
16
+ /** 构建期打好的那份 preload(`prepareShell` 里调 {@link bundlePreload} 产出)。 */
17
+ export declare const PRELOAD_BUNDLE_FILE: string;
18
+ /**
19
+ * 在宿主的 webServer 上注册 `/@dsh/preload.cjs`:壳按这条路径取走 preload 的 CJS 字节。
20
+ *
21
+ * **读的是构建期打好的文件**,不在运行时打包:现场打包要读 `src/preload-app.ts`,而打包器(rolldown)
22
+ * 是 native 模块、用自己的文件 IO——**读不了 asar 内的路径**(宿主跑在 Electron 上,`fs` 才被补丁照看着)。
23
+ * 构建期那份读进来是字节,`readFile` 对归档是透明的。
24
+ *
25
+ * @param ctx - 宿主上下文(拿 `webServer`)。
26
+ * @param options - 见 {@link PreloadSourceOptions}。
27
+ */
28
+ export declare function installPreloadSource(ctx: Context, options: PreloadSourceOptions): void;
29
+ //#endregion