@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/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
|
|
5
|
+
上游 `apps/desktop-host`(`@deepseek-ai/dsh-desktop-host`)是 `private: true` 的应用,外部装不到,所以这里是它的**变体包**:IPC 契约照旧,argv 与本仓库的 web 入口统一成一套 flag(见下)。
|
|
6
6
|
|
|
7
7
|
## 用法
|
|
8
8
|
|
|
9
|
-
被 [`@
|
|
9
|
+
被 [`@local/dshdev`](../../../devpackages/dshdev/README.md) 使用,不单独跑:
|
|
10
10
|
|
|
11
|
-
- `dev` / `bundle` 把本包落位到部署的 `<runtime>/node_modules/@morlay/dsh-desktop-host`(`dev`
|
|
12
|
-
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
2
|
-
#
|
|
3
|
-
#
|
|
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
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
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:
|
|
36
|
+
- id: product-analytics
|
|
47
37
|
disabled: true
|
|
48
38
|
|
|
49
|
-
|
|
50
|
-
|
|
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"
|
package/dist/index.d.mts
ADDED
|
@@ -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 {};
|
package/dist/paths.d.mts
ADDED
package/dist/paths.js
ADDED
|
@@ -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
|