@morlay/dsh-desktop-host 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.
- 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 +41 -17
- 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,80 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { rolldown } from "rolldown";
|
|
4
|
+
//#region src/preload-source.ts
|
|
5
|
+
/**
|
|
6
|
+
* preload 的**源码**由服务现场打包成 CJS 交给壳,装配目录里因此不再有 `dist/`。
|
|
7
|
+
*
|
|
8
|
+
* 为什么需要它:sandboxed preload 有三个硬约束——只能 CJS、不能 ESM、不能 `require` 第三方(因此也挂不了
|
|
9
|
+
* 本仓库那套 TS hooks)。而它的源码是 TS(还跨几个文件:`preload-app.ts` 拉 `./stream-uplink.ts` 那些),
|
|
10
|
+
* 所以只能**打成单文件 CJS**——打包这件事放在这里做,壳就不必在装配时预先产出一份 `dist/`。
|
|
11
|
+
*
|
|
12
|
+
* 谁来取:壳在窗口创建之前(那时宿主已经就绪)取回来落盘,再把路径交给 `BrowserWindow` 的 `preload`。
|
|
13
|
+
*/
|
|
14
|
+
/** 壳取 preload 的路径(`GET` 它得到可执行的 CJS 字节)。 */
|
|
15
|
+
const PRELOAD_SOURCE_PATH = "/@dsh/preload.cjs";
|
|
16
|
+
/**
|
|
17
|
+
* 把 preload 打包成单文件 CJS。
|
|
18
|
+
*
|
|
19
|
+
* @param options - 见 {@link PreloadSourceOptions}。
|
|
20
|
+
* @returns CJS 源码。
|
|
21
|
+
*/
|
|
22
|
+
async function bundlePreload(options) {
|
|
23
|
+
const bundle = await rolldown({
|
|
24
|
+
input: join(options.shellRoot, "src", "preload-app.ts"),
|
|
25
|
+
platform: "node",
|
|
26
|
+
external: ["electron"]
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
const { output } = await bundle.generate({
|
|
30
|
+
format: "cjs",
|
|
31
|
+
inlineDynamicImports: true
|
|
32
|
+
});
|
|
33
|
+
const chunk = output.find((item) => item.type === "chunk");
|
|
34
|
+
if (chunk === void 0) throw new Error("dsh desktop: preload bundling produced no chunk");
|
|
35
|
+
return chunk.code;
|
|
36
|
+
} finally {
|
|
37
|
+
await bundle.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** 构建期打好的那份 preload(`prepareShell` 里调 {@link bundlePreload} 产出)。 */
|
|
41
|
+
const PRELOAD_BUNDLE_FILE = join("dist", "preload.cjs");
|
|
42
|
+
/**
|
|
43
|
+
* 在宿主的 webServer 上注册 `/@dsh/preload.cjs`:壳按这条路径取走 preload 的 CJS 字节。
|
|
44
|
+
*
|
|
45
|
+
* **读的是构建期打好的文件**,不在运行时打包:现场打包要读 `src/preload-app.ts`,而打包器(rolldown)
|
|
46
|
+
* 是 native 模块、用自己的文件 IO——**读不了 asar 内的路径**(宿主跑在 Electron 上,`fs` 才被补丁照看着)。
|
|
47
|
+
* 构建期那份读进来是字节,`readFile` 对归档是透明的。
|
|
48
|
+
*
|
|
49
|
+
* @param ctx - 宿主上下文(拿 `webServer`)。
|
|
50
|
+
* @param options - 见 {@link PreloadSourceOptions}。
|
|
51
|
+
*/
|
|
52
|
+
function installPreloadSource(ctx, options) {
|
|
53
|
+
const file = join(options.shellRoot, PRELOAD_BUNDLE_FILE);
|
|
54
|
+
const serve = (req, res) => {
|
|
55
|
+
(async () => {
|
|
56
|
+
let code;
|
|
57
|
+
try {
|
|
58
|
+
code = await readFile(file);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
61
|
+
ctx.logger.error(`dsh desktop: preload bundle is unreadable at ${file}: ${message}`);
|
|
62
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
63
|
+
res.end(`dsh desktop: preload bundle is unreadable at ${file}: ${message}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
res.writeHead(200, {
|
|
67
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
68
|
+
"cache-control": "no-store"
|
|
69
|
+
});
|
|
70
|
+
res.end(req.method === "HEAD" ? void 0 : code);
|
|
71
|
+
})();
|
|
72
|
+
};
|
|
73
|
+
ctx.effect(() => ctx.webServer.register({
|
|
74
|
+
kind: "exact",
|
|
75
|
+
path: PRELOAD_SOURCE_PATH,
|
|
76
|
+
handler: serve
|
|
77
|
+
}), `dsh-desktop-host: ${PRELOAD_SOURCE_PATH}`);
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
export { PRELOAD_BUNDLE_FILE, PRELOAD_SOURCE_PATH, bundlePreload, installPreloadSource };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
//#region src/settings-namespace.d.ts
|
|
3
|
+
export declare const name = "@morlay/dsh-desktop-host-settings-namespace";
|
|
4
|
+
/**
|
|
5
|
+
* 注册 settings namespace(`auto: false` 表示这个 namespace 的界面由宿主自己安排,不走自动渲染)。
|
|
6
|
+
* @param ctx - 宿主的上下文。
|
|
7
|
+
*/
|
|
8
|
+
export declare function apply(ctx: Context): void;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/settings-namespace.ts
|
|
2
|
+
const name = "@morlay/dsh-desktop-host-settings-namespace";
|
|
3
|
+
/**
|
|
4
|
+
* 注册 settings namespace(`auto: false` 表示这个 namespace 的界面由宿主自己安排,不走自动渲染)。
|
|
5
|
+
* @param ctx - 宿主的上下文。
|
|
6
|
+
*/
|
|
7
|
+
function apply(ctx) {
|
|
8
|
+
ctx.inject(["settings"], (scope) => {
|
|
9
|
+
scope.effect(() => scope.settings.configure({ auto: false }, ctx.fiber));
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { apply, name };
|
package/dist/wire.d.mts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
//#region src/wire.d.ts
|
|
2
|
+
export declare const DESKTOP_HOST_PROTOCOL_VERSION: 1;
|
|
3
|
+
export declare const DESKTOP_STREAM_PATH = "/.dsh/remote-stream";
|
|
4
|
+
export declare function encodeDesktopStreamOpen(endpoint: string, payload: unknown): string;
|
|
5
|
+
export declare function encodeDesktopStreamItem(item: unknown): string;
|
|
6
|
+
export declare const DESKTOP_REQUEST_PIPE_FD = 3;
|
|
7
|
+
export declare const DESKTOP_RESPONSE_PIPE_FD = 4;
|
|
8
|
+
export declare const DESKTOP_CONTROL_IPC_FD = 5;
|
|
9
|
+
export declare const DESKTOP_PIPE_CHUNK_BYTES: number;
|
|
10
|
+
export interface DesktopHostRequestStart {
|
|
11
|
+
readonly url: string;
|
|
12
|
+
readonly method: string;
|
|
13
|
+
readonly headers: readonly [string, string][];
|
|
14
|
+
readonly hasBody: boolean;
|
|
15
|
+
}
|
|
16
|
+
export type DesktopHostRequestFrame = ({
|
|
17
|
+
readonly type: "start";
|
|
18
|
+
readonly streamId: number;
|
|
19
|
+
} & DesktopHostRequestStart) | {
|
|
20
|
+
readonly type: "data";
|
|
21
|
+
readonly streamId: number;
|
|
22
|
+
readonly data: Buffer;
|
|
23
|
+
} | {
|
|
24
|
+
readonly type: "end";
|
|
25
|
+
readonly streamId: number;
|
|
26
|
+
} | {
|
|
27
|
+
readonly type: "cancel";
|
|
28
|
+
readonly streamId: number;
|
|
29
|
+
};
|
|
30
|
+
export type DesktopHostResponseFrame = {
|
|
31
|
+
readonly type: "start";
|
|
32
|
+
readonly streamId: number;
|
|
33
|
+
readonly status: number;
|
|
34
|
+
readonly headers: readonly [string, string][];
|
|
35
|
+
readonly hasBody: boolean;
|
|
36
|
+
} | {
|
|
37
|
+
readonly type: "data";
|
|
38
|
+
readonly streamId: number;
|
|
39
|
+
readonly data: Buffer;
|
|
40
|
+
} | {
|
|
41
|
+
readonly type: "end";
|
|
42
|
+
readonly streamId: number;
|
|
43
|
+
} | {
|
|
44
|
+
readonly type: "error";
|
|
45
|
+
readonly streamId: number;
|
|
46
|
+
readonly message: string;
|
|
47
|
+
};
|
|
48
|
+
export type DesktopHostCommand = {
|
|
49
|
+
readonly type: "shutdown";
|
|
50
|
+
} | {
|
|
51
|
+
readonly type: "inspect";
|
|
52
|
+
readonly port: number;
|
|
53
|
+
} | {
|
|
54
|
+
readonly type: "inspect-off";
|
|
55
|
+
};
|
|
56
|
+
export type DesktopHostEvent = {
|
|
57
|
+
readonly type: "ready";
|
|
58
|
+
readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION;
|
|
59
|
+
/**
|
|
60
|
+
* webServer 的 origin(`http://127.0.0.1:<port>`):壳用它载入窗口。基于 URL/WS 的东西(settings 的
|
|
61
|
+
* describe 流、账号的 stream mux、vite 的 HMR)要求窗口来源就是它。
|
|
62
|
+
*/
|
|
63
|
+
readonly origin: string;
|
|
64
|
+
} | {
|
|
65
|
+
readonly type: "fatal";
|
|
66
|
+
readonly message: string;
|
|
67
|
+
readonly diagnostic?: string;
|
|
68
|
+
} | {
|
|
69
|
+
readonly type: "inspected";
|
|
70
|
+
readonly url: string | null;
|
|
71
|
+
readonly message?: string;
|
|
72
|
+
};
|
|
73
|
+
export declare function encodeDesktopRequestStart(streamId: number, request: DesktopHostRequestStart): Buffer;
|
|
74
|
+
export declare function encodeDesktopRequestData(streamId: number, data: Uint8Array): Buffer;
|
|
75
|
+
export declare function encodeDesktopRequestEnd(streamId: number): Buffer;
|
|
76
|
+
export declare function encodeDesktopRequestCancel(streamId: number): Buffer;
|
|
77
|
+
export declare function encodeDesktopResponseStart(streamId: number, response: {
|
|
78
|
+
readonly status: number;
|
|
79
|
+
readonly headers: readonly [string, string][];
|
|
80
|
+
readonly hasBody: boolean;
|
|
81
|
+
}): Buffer;
|
|
82
|
+
export declare function encodeDesktopResponseData(streamId: number, data: Uint8Array): Buffer;
|
|
83
|
+
export declare function encodeDesktopResponseEnd(streamId: number): Buffer;
|
|
84
|
+
export declare function encodeDesktopResponseError(streamId: number, message: string): Buffer;
|
|
85
|
+
export declare class DesktopStreamBodyDecoder {
|
|
86
|
+
private readonly text;
|
|
87
|
+
private buffer;
|
|
88
|
+
push(chunk: Uint8Array): unknown[];
|
|
89
|
+
finish(): unknown[];
|
|
90
|
+
private take;
|
|
91
|
+
}
|
|
92
|
+
export declare class DesktopHostRequestDecoder {
|
|
93
|
+
private buffer;
|
|
94
|
+
push(chunk: Buffer): DesktopHostRequestFrame[];
|
|
95
|
+
finish(): void;
|
|
96
|
+
private next;
|
|
97
|
+
private parseStart;
|
|
98
|
+
}
|
|
99
|
+
export declare class DesktopHostResponseDecoder {
|
|
100
|
+
private buffer;
|
|
101
|
+
push(chunk: Buffer): DesktopHostResponseFrame[];
|
|
102
|
+
finish(): void;
|
|
103
|
+
private next;
|
|
104
|
+
private parseStart;
|
|
105
|
+
private parseError;
|
|
106
|
+
}
|
|
107
|
+
export declare function isDesktopHostEvent(value: unknown): value is DesktopHostEvent;
|
|
108
|
+
export declare function isDesktopHostCommand(value: unknown): value is DesktopHostCommand;
|
|
109
|
+
//#endregion
|
package/{lib → dist}/wire.js
RENAMED
|
@@ -254,10 +254,13 @@ function isDesktopHostEvent(value) {
|
|
|
254
254
|
if (!isRecord(value)) return false;
|
|
255
255
|
if (value.type === "ready") return value.protocolVersion === 1;
|
|
256
256
|
if (value.type === "fatal") return typeof value.message === "string" && (value.diagnostic === void 0 || typeof value.diagnostic === "string");
|
|
257
|
+
if (value.type === "inspected") return (value.url === null || typeof value.url === "string") && (value.message === void 0 || typeof value.message === "string");
|
|
257
258
|
return false;
|
|
258
259
|
}
|
|
259
260
|
function isDesktopHostCommand(value) {
|
|
260
|
-
|
|
261
|
+
if (!isRecord(value)) return false;
|
|
262
|
+
if (value.type === "shutdown" || value.type === "inspect-off") return true;
|
|
263
|
+
return value.type === "inspect" && Number.isInteger(value.port) && value.port >= 0 && value.port <= 65535;
|
|
261
264
|
}
|
|
262
265
|
//#endregion
|
|
263
266
|
export { DESKTOP_CONTROL_IPC_FD, DESKTOP_HOST_PROTOCOL_VERSION, DESKTOP_PIPE_CHUNK_BYTES, DESKTOP_REQUEST_PIPE_FD, DESKTOP_RESPONSE_PIPE_FD, DESKTOP_STREAM_PATH, DesktopHostRequestDecoder, DesktopHostResponseDecoder, DesktopStreamBodyDecoder, encodeDesktopRequestCancel, encodeDesktopRequestData, encodeDesktopRequestEnd, encodeDesktopRequestStart, encodeDesktopResponseData, encodeDesktopResponseEnd, encodeDesktopResponseError, encodeDesktopResponseStart, encodeDesktopStreamItem, encodeDesktopStreamOpen, isDesktopHostCommand, isDesktopHostEvent };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morlay/dsh-desktop-host",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Desktop host process for
|
|
3
|
+
"version": "0.2.7",
|
|
4
|
+
"description": "Desktop host process for dshdev: boots the desktop profile and serves it through an in-process, portless webServer over byte pipes — upstream apps/desktop-host with the web carrier taken over and the Office composition dropped.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"desktop",
|
|
7
7
|
"dsh",
|
|
@@ -16,37 +16,61 @@
|
|
|
16
16
|
"url": "https://github.com/morlay/dsh-plugin.git"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
+
"src",
|
|
19
20
|
"locale/*.json",
|
|
20
|
-
"
|
|
21
|
+
"dist",
|
|
21
22
|
"config/desktop.cordis.patch.yml"
|
|
22
23
|
],
|
|
23
24
|
"type": "module",
|
|
24
|
-
"main": "./lib/index.js",
|
|
25
25
|
"exports": {
|
|
26
|
-
".":
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.mts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./preload-source": {
|
|
31
|
+
"types": "./dist/preload-source.d.mts",
|
|
32
|
+
"default": "./dist/preload-source.js"
|
|
33
|
+
},
|
|
34
|
+
"./wire": {
|
|
35
|
+
"types": "./dist/wire.d.mts",
|
|
36
|
+
"default": "./dist/wire.js"
|
|
37
|
+
},
|
|
38
|
+
"./paths": {
|
|
39
|
+
"types": "./dist/paths.d.mts",
|
|
40
|
+
"default": "./dist/paths.js"
|
|
41
|
+
},
|
|
42
|
+
"./settings-namespace": {
|
|
43
|
+
"types": "./dist/settings-namespace.d.mts",
|
|
44
|
+
"default": "./dist/settings-namespace.js"
|
|
45
|
+
},
|
|
29
46
|
"./package.json": "./package.json",
|
|
30
47
|
"./locale/*.json": "./locale/*.json"
|
|
31
48
|
},
|
|
32
49
|
"dependencies": {
|
|
33
|
-
"@deepseek-ai/dsh": "^0.2.
|
|
34
|
-
"@deepseek-ai/
|
|
35
|
-
"@
|
|
36
|
-
"@
|
|
50
|
+
"@deepseek-ai/dsh-host-webserver": "^0.2.1-rc.1",
|
|
51
|
+
"@deepseek-ai/schemastery": "^3.18.5",
|
|
52
|
+
"@morlay/dsh-app-boot": "0.2.7",
|
|
53
|
+
"@morlay/dsh-client-modules": "0.2.8",
|
|
54
|
+
"@morlay/web-custom-profile": "0.0.2",
|
|
55
|
+
"lightningcss": "^1.32.0",
|
|
56
|
+
"rolldown": "1.x",
|
|
57
|
+
"ws": "^8.21.3"
|
|
37
58
|
},
|
|
38
59
|
"devDependencies": {
|
|
39
|
-
"@deepseek-ai/cordis": "4.0.
|
|
40
|
-
"@deepseek-ai/dsh-api-gateway": "0.2.0-rc.2",
|
|
41
|
-
"@deepseek-ai/dsh-client-connection": "0.2.0-rc.2",
|
|
60
|
+
"@deepseek-ai/cordis": "^4.0.5",
|
|
42
61
|
"@local/devkit": "0.1.0",
|
|
43
|
-
"@types/node": "^26.5.0"
|
|
62
|
+
"@types/node": "^26.5.0",
|
|
63
|
+
"@types/ws": "^8.18.2",
|
|
64
|
+
"playwright": "^1.64.0"
|
|
44
65
|
},
|
|
45
66
|
"peerDependencies": {
|
|
46
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
67
|
+
"@deepseek-ai/cordis": "^4.0.5",
|
|
68
|
+
"@deepseek-ai/dsh-api-gateway": "^0.2.1-rc.1",
|
|
69
|
+
"@deepseek-ai/dsh-client-connection": "^0.2.1-rc.1"
|
|
47
70
|
},
|
|
48
71
|
"engines": {
|
|
49
|
-
"node": ">=24.11.0"
|
|
72
|
+
"node": ">=24.11.0",
|
|
73
|
+
"dsh": ">= 0.2.1-rc.1 < 0.2.2"
|
|
50
74
|
},
|
|
51
75
|
"scripts": {
|
|
52
76
|
"build": "pnpm exec tsdown"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
const repoRoot = process.cwd();
|
|
6
|
+
const PATCH_PATH = join(
|
|
7
|
+
repoRoot,
|
|
8
|
+
"packages/desktop/dsh-desktop-host/config/desktop.cordis.patch.yml",
|
|
9
|
+
);
|
|
10
|
+
const BUNDLE_ROOT = join(repoRoot, "vendor/deepseek-harness/packages/bundle");
|
|
11
|
+
// 前端服务(`@morlay/dsh-client-modules`)那一行:单独一层,web 形态也要挂(用 `--patch`)。
|
|
12
|
+
const DEV_CLIENT_PATCH_PATH = join(
|
|
13
|
+
repoRoot,
|
|
14
|
+
"packages/bundles/web-custom-profile/cordis.patch.yml",
|
|
15
|
+
);
|
|
16
|
+
const devClientPatch = await readFile(DEV_CLIENT_PATCH_PATH, "utf8");
|
|
17
|
+
const UPSTREAM_PATCHES = ["base/cordis.patch.yml", "web-app/cordis.patch.yml"];
|
|
18
|
+
|
|
19
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
await stat(path);
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const patch = await readFile(PATCH_PATH, "utf8");
|
|
29
|
+
|
|
30
|
+
// 停用/配置的目标行 id(顶层 `- id:`,不含 insert 出来的行)。
|
|
31
|
+
function disabledIds(text: string): string[] {
|
|
32
|
+
return [...text.matchAll(/^- id: (\S+)\n {2}disabled: true$/gm)].map((match) => match[1]!);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function configuredIds(text: string): string[] {
|
|
36
|
+
return [...text.matchAll(/^- id: (\S+)\n {2}config:$/gm)].map((match) => match[1]!);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function insertedNames(text: string): string[] {
|
|
40
|
+
const names: string[] = [];
|
|
41
|
+
for (const block of text.split(/^- insert:\s*$/m).slice(1)) {
|
|
42
|
+
names.push(
|
|
43
|
+
...[...block.matchAll(/^ {4}- id: (\S+)\n {6}name: "([^"]+)"$/gm)].map((m) => m[2]!),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return names;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 上游 patch 里的 `行 id → 模块名`(本 overlay 只按 id 瞄准,名字留在上游那一份)。
|
|
50
|
+
async function upstreamModules(): Promise<Map<string, string>> {
|
|
51
|
+
const modules = new Map<string, string>();
|
|
52
|
+
for (const file of UPSTREAM_PATCHES) {
|
|
53
|
+
const text = await readFile(join(BUNDLE_ROOT, file), "utf8");
|
|
54
|
+
for (const match of text.matchAll(/^\s*- id: (\S+)\n\s+name: ['"]([^'"]+)['"]/gm))
|
|
55
|
+
modules.set(match[1]!, match[2]!);
|
|
56
|
+
}
|
|
57
|
+
return modules;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const upstream = await upstreamModules();
|
|
61
|
+
|
|
62
|
+
// 账号面的取舍**不在桌面档**:本部署不含登录,那几条由 profile 档
|
|
63
|
+
// [`@morlay/mydsh-profile`](../../../bundles/mydsh-profile/tsdown.config.ts) 停(web 形态也同样受益)。
|
|
64
|
+
// 桌面档这里只停"桌面形态"自己的两件事:传输接管(`webserver` → `portless-webserver`)与产品遥测。
|
|
65
|
+
//
|
|
66
|
+
// 为什么非要分家:应用顺序是 `bundle 层 → overlay`,而桌面 overlay 由宿主追加在 `patchFiles` 末尾
|
|
67
|
+
// (`src/index.ts:80`)——排最后、覆盖前面所有层。取舍写在 overlay 里,profile 档的同 id 设置就"改不动"。
|
|
68
|
+
// 本部署不要账号 UI:停上游那一行,由 profile 档的空壳行补上它的 settings namespace 注册。
|
|
69
|
+
const ACCOUNT_ROWS: Readonly<Record<string, string>> = {
|
|
70
|
+
"ui-settings-account": "@deepseek-ai/dsh-client-ui-settings-account",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// 桌面档**必须**停的产品遥测面:两行只在 profile 名为 `desktop` 时激活,靠构建期环境变量
|
|
74
|
+
// `DSH_CLIENT_VERSION` 填 `serviceVersion`(必填);自研壳不传那个变量 → 遥测行配置校验失败、
|
|
75
|
+
// 产品分析行 pending,页面启动时那条 watchPolicy 流拿 502。本地桌面部署没有上报对象。
|
|
76
|
+
const TELEMETRY_ROWS = {
|
|
77
|
+
"desktop-product-telemetry": "@deepseek-ai/dsh-host-product-telemetry-otel",
|
|
78
|
+
"product-analytics": "@deepseek-ai/dsh-client-product-analytics",
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
81
|
+
// 桌面档**故意不动**的行:它们不是登录面,模型路径靠它们。
|
|
82
|
+
const KEPT_ROWS = {
|
|
83
|
+
credentials: "@deepseek-ai/dsh-credentials-local",
|
|
84
|
+
authorization: "@deepseek-ai/dsh-authorization",
|
|
85
|
+
"llm-deepseek": "@deepseek-ai/dsh-llm-deepseek-api-key",
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
describe("桌面 overlay 的停用清单", () => {
|
|
89
|
+
it("瞄准的上游行都存在,且行 id 现在确实指向那个模块", () => {
|
|
90
|
+
for (const [id, module] of [
|
|
91
|
+
...Object.entries(ACCOUNT_ROWS),
|
|
92
|
+
...Object.entries(TELEMETRY_ROWS),
|
|
93
|
+
...Object.entries(KEPT_ROWS),
|
|
94
|
+
["webserver", "@deepseek-ai/dsh-host-webserver"] as const,
|
|
95
|
+
]) {
|
|
96
|
+
expect(upstream.get(id), `upstream row ${id} moved or was renamed`).toBe(module);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("停用清单是产品遥测 + 账号 UI(后者由 profile 档的空壳行接替)", () => {
|
|
101
|
+
// 停用必须在 overlay:写在 bundle 层会被后来应用的层盖回去(实测),而 overlay 排最后。
|
|
102
|
+
// 停的是"账号 UI"那一行——它同时是 settings namespace 的 owner,所以由 profile 档补一个空壳注册。
|
|
103
|
+
expect(disabledIds(patch).sort()).toEqual(
|
|
104
|
+
["ui-settings-account", ...Object.keys(TELEMETRY_ROWS)].sort(),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("桌面档停的浏览器模块是产品分析与账号 UI", () => {
|
|
109
|
+
const clientModules = [...Object.values(ACCOUNT_ROWS), ...Object.values(TELEMETRY_ROWS)].filter(
|
|
110
|
+
(module) => module.startsWith("@deepseek-ai/dsh-client-"),
|
|
111
|
+
);
|
|
112
|
+
expect(clientModules.sort()).toEqual([
|
|
113
|
+
"@deepseek-ai/dsh-client-product-analytics",
|
|
114
|
+
"@deepseek-ai/dsh-client-ui-settings-account",
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("前端服务那一行单独一层,按包名指到 @morlay/dsh-client-modules", async () => {
|
|
119
|
+
expect(insertedNames(devClientPatch)).toEqual(["@morlay/dsh-client-modules"]);
|
|
120
|
+
const built = join(repoRoot, "packages/client/dsh-client-modules/dist/index.js");
|
|
121
|
+
if (await pathExists(built)) expect((await stat(built)).isFile()).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("模型路径那几行没有一起被停", () => {
|
|
125
|
+
for (const id of Object.keys(KEPT_ROWS)) expect(disabledIds(patch)).not.toContain(id);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("不再替换 webServer;配 web-runtime 与它的随机端口", () => {
|
|
129
|
+
// 以前这里禁掉 `webserver` 并插一个"无端口替身"(请求走字节管道喂进来)。那条路没有 origin,
|
|
130
|
+
// 基于 URL/WS 的东西(settings 的 describe 流、账号的 stream mux、vite 的 HMR)拿不到答复——
|
|
131
|
+
// 现在照常 listen,壳用它的 origin 载入窗口。
|
|
132
|
+
expect(insertedNames(patch)).toEqual(["../src/settings-namespace.ts"]);
|
|
133
|
+
expect(disabledIds(patch)).not.toContain("webserver");
|
|
134
|
+
expect(configuredIds(patch).sort()).toEqual(["web-runtime", "webserver"]);
|
|
135
|
+
// 插入的那一行是"账号面空壳":顶替被停掉的 `ui-settings-account` 那个 settings namespace 注册。
|
|
136
|
+
// 端口必须随机:上游 `web-app` 给的是 `webStartup.port ?? 3080`,桌面没有 `--port`,于是落到 3080
|
|
137
|
+
// ——两个实例或本机别的工具占了它,第二次就起不来。
|
|
138
|
+
expect(portOfWebServer(patch)).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("停用各归其位:遥测与官方账号实现都在桌面 overlay(写在 bundle 层会被盖回去)", async () => {
|
|
142
|
+
const overlay = await readFile(
|
|
143
|
+
join(repoRoot, "packages/desktop/dsh-desktop-host/config/desktop.cordis.patch.yml"),
|
|
144
|
+
"utf8",
|
|
145
|
+
);
|
|
146
|
+
// 桌面 overlay 只管桌面形态自己那两件:传输接管与产品遥测。
|
|
147
|
+
for (const id of Object.keys(TELEMETRY_ROWS)) {
|
|
148
|
+
expect(overlay, `overlay 该停 ${id}`).toContain(id);
|
|
149
|
+
}
|
|
150
|
+
// 账号实现必须在本 overlay 里(它是最后应用的一层);写在 profile 档里会被后来应用的层盖回去。
|
|
151
|
+
for (const id of Object.keys(ACCOUNT_ROWS)) {
|
|
152
|
+
expect(overlay, `overlay 该停在 ${id}`).toContain(id);
|
|
153
|
+
}
|
|
154
|
+
const profile = await readFile(
|
|
155
|
+
join(repoRoot, "packages/bundles/mydsh-profile/cordis.patch.yml"),
|
|
156
|
+
"utf8",
|
|
157
|
+
);
|
|
158
|
+
// profile 档不承担停用(写那儿不生效);它负责的是"空壳注册"。
|
|
159
|
+
expect(profile, "profile 档不该出现停用").not.toContain("disabled: true");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/** 桌面 overlay 给 `webserver` 定的端口。 */
|
|
164
|
+
function portOfWebServer(source: string): number | undefined {
|
|
165
|
+
const row = source.split("\n- id: ").find((chunk) => chunk.startsWith("webserver"));
|
|
166
|
+
return row === undefined
|
|
167
|
+
? undefined
|
|
168
|
+
: /port: (\d+)/.exec(row)?.[1] === undefined
|
|
169
|
+
? undefined
|
|
170
|
+
: Number(/port: (\d+)/.exec(row)?.[1]);
|
|
171
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import inspector from "node:inspector";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { applyInspectorCommand, closeInspectorEndpoint } from "../inspector.ts";
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
closeInspectorEndpoint();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe("调试端点开关", () => {
|
|
10
|
+
it("开在回环的随机端口上并回报端点,关掉后端点消失", () => {
|
|
11
|
+
const event = applyInspectorCommand({ type: "inspect", port: 0 });
|
|
12
|
+
expect(event?.type).toBe("inspected");
|
|
13
|
+
const url = event?.url ?? "";
|
|
14
|
+
expect(url).toMatch(/^ws:\/\/127\.0\.0\.1:\d+\/[0-9a-f-]+$/u);
|
|
15
|
+
expect(url).not.toContain(":0/");
|
|
16
|
+
expect(inspector.url()).toBe(url);
|
|
17
|
+
|
|
18
|
+
expect(applyInspectorCommand({ type: "inspect-off" })).toEqual({
|
|
19
|
+
type: "inspected",
|
|
20
|
+
url: null,
|
|
21
|
+
});
|
|
22
|
+
expect(inspector.url()).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("已有端点时复用而不是换端口(dev 的 --inspect 启动参数占着的那个)", () => {
|
|
26
|
+
const first = applyInspectorCommand({ type: "inspect", port: 0 });
|
|
27
|
+
const second = applyInspectorCommand({ type: "inspect", port: 0 });
|
|
28
|
+
expect(second).toEqual(first);
|
|
29
|
+
expect(inspector.url()).toBe(first?.url);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("端口非法时报原因而不是抛出", () => {
|
|
33
|
+
const event = applyInspectorCommand({ type: "inspect", port: 65_536 });
|
|
34
|
+
expect(event?.type).toBe("inspected");
|
|
35
|
+
expect(event?.url).toBeNull();
|
|
36
|
+
expect(event?.message).toBeTruthy();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("重复关闭是幂等的,非调试命令不回应", () => {
|
|
40
|
+
expect(applyInspectorCommand({ type: "inspect-off" })).toEqual({
|
|
41
|
+
type: "inspected",
|
|
42
|
+
url: null,
|
|
43
|
+
});
|
|
44
|
+
expect(applyInspectorCommand({ type: "inspect-off" })).toEqual({
|
|
45
|
+
type: "inspected",
|
|
46
|
+
url: null,
|
|
47
|
+
});
|
|
48
|
+
expect(applyInspectorCommand({ type: "shutdown" })).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseHostInvocation } from "../invocation.ts";
|
|
3
|
+
|
|
4
|
+
// 两个入口(desktop 的 `index.ts`、web 的 `web.ts`)共用这一套参数:这是"形态之间唯一该不同的地方"
|
|
5
|
+
// 之外的全部输入——两形态从这里拿到的东西必须同形,否则"哪一层生效"又会两边不一致。
|
|
6
|
+
describe("宿主入口的参数(desktop / web 共用)", () => {
|
|
7
|
+
it("取两格锚点、overlay、包通道与 `--` 之后的内层参数", () => {
|
|
8
|
+
expect(
|
|
9
|
+
parseHostInvocation("dsh desktop", [
|
|
10
|
+
"--profile-dir",
|
|
11
|
+
"/profiles/desktop",
|
|
12
|
+
"--runtime-dir",
|
|
13
|
+
"/runtime",
|
|
14
|
+
"--patch",
|
|
15
|
+
"/host/desktop.cordis.patch.yml",
|
|
16
|
+
"--patch",
|
|
17
|
+
"/extra.yml",
|
|
18
|
+
"--pnpm",
|
|
19
|
+
"/runtime/pnpm",
|
|
20
|
+
"--node-bin",
|
|
21
|
+
"/runtime/node/bin",
|
|
22
|
+
"--",
|
|
23
|
+
"--no-open",
|
|
24
|
+
]),
|
|
25
|
+
).toEqual({
|
|
26
|
+
profileDir: "/profiles/desktop",
|
|
27
|
+
runtimeDir: "/runtime",
|
|
28
|
+
patches: ["/host/desktop.cordis.patch.yml", "/extra.yml"],
|
|
29
|
+
pnpmEntry: "/runtime/pnpm",
|
|
30
|
+
nodeBin: "/runtime/node/bin",
|
|
31
|
+
inner: ["--no-open"],
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("包通道与 overlay 都可省(dev 形态不传 pnpm 载荷)", () => {
|
|
36
|
+
const invocation = parseHostInvocation("dsh desktop", [
|
|
37
|
+
"--profile-dir",
|
|
38
|
+
"/app",
|
|
39
|
+
"--runtime-dir",
|
|
40
|
+
"/store",
|
|
41
|
+
]);
|
|
42
|
+
expect(invocation.pnpmEntry).toBeUndefined();
|
|
43
|
+
expect(invocation.nodeBin).toBeUndefined();
|
|
44
|
+
expect(invocation.patches).toEqual([]);
|
|
45
|
+
expect(invocation.inner).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("缺锚点、缺值、参数不认识时都失败,且报出是哪个入口", () => {
|
|
49
|
+
expect(() => parseHostInvocation("dsh desktop", [])).toThrow(/dsh desktop: .*--profile-dir/u);
|
|
50
|
+
expect(() => parseHostInvocation("dsh desktop", ["--profile-dir", "/app"])).toThrow(
|
|
51
|
+
/--runtime-dir/u,
|
|
52
|
+
);
|
|
53
|
+
expect(() =>
|
|
54
|
+
parseHostInvocation("dsh desktop", ["--profile-dir", "/app", "--runtime-dir"]),
|
|
55
|
+
).toThrow(/needs a value/u);
|
|
56
|
+
expect(() => parseHostInvocation("dsh web", ["--nope"])).toThrow(
|
|
57
|
+
/dsh web: unknown argument "--nope"/u,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { access, readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
const PACKAGE_ROOT = dirname(
|
|
7
|
+
fileURLToPath(import.meta.resolve("@morlay/dsh-desktop-host/package.json")),
|
|
8
|
+
);
|
|
9
|
+
const BUILD_DIR = join(PACKAGE_ROOT, "lib");
|
|
10
|
+
const BUILT = await access(join(BUILD_DIR, "index.js")).then(
|
|
11
|
+
() => true,
|
|
12
|
+
() => false,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
interface Manifest {
|
|
16
|
+
readonly dependencies?: Record<string, string>;
|
|
17
|
+
readonly peerDependencies?: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 产物里的裸 import 说明运行期依赖;相对路径与 node: 内建除外。
|
|
21
|
+
async function builtImports(): Promise<Set<string>> {
|
|
22
|
+
const names = new Set<string>();
|
|
23
|
+
for (const entry of await readdir(BUILD_DIR)) {
|
|
24
|
+
if (!entry.endsWith(".js")) continue;
|
|
25
|
+
const source = await readFile(join(BUILD_DIR, entry), "utf8");
|
|
26
|
+
// 只认行首的 ESM import 语句:产物里的字符串字面量也含 "import"。
|
|
27
|
+
for (const match of source.matchAll(/^import\s+(?:[^"'`]*?\sfrom\s+)?"([^"]+)"/gmu)) {
|
|
28
|
+
const specifier = match[1] as string;
|
|
29
|
+
if (specifier.startsWith(".") || specifier.startsWith("node:")) continue;
|
|
30
|
+
const segments = specifier.split("/");
|
|
31
|
+
names.add(
|
|
32
|
+
segments[0]?.startsWith("@") === true ? `${segments[0]}/${segments[1]}` : segments[0]!,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return names;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("desktop host 发布清单", () => {
|
|
40
|
+
it("不声明上游 app 的组合依赖(office 组合整个不挂)", async () => {
|
|
41
|
+
const manifest = JSON.parse(
|
|
42
|
+
await readFile(join(PACKAGE_ROOT, "package.json"), "utf8"),
|
|
43
|
+
) as Manifest;
|
|
44
|
+
|
|
45
|
+
// 变体不挂 Office 组合:docx / pptx / xlsx 技能与它那份载荷工具都由上游 app manifest 声明,抄来会让部署照装。
|
|
46
|
+
for (const name of [
|
|
47
|
+
"@deepseek-ai/dsh-skill-office",
|
|
48
|
+
"@deepseek-ai/dsh-tool-workspace-dependencies",
|
|
49
|
+
"@deepseek-ai/dsh-agent",
|
|
50
|
+
"@deepseek-ai/dsh-jobs",
|
|
51
|
+
"@deepseek-ai/dsh-tools",
|
|
52
|
+
]) {
|
|
53
|
+
expect(manifest.dependencies?.[name]).toBeUndefined();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it.skipIf(!BUILT)("清单与产物互相覆盖:声明即用到,用到即声明", async () => {
|
|
58
|
+
const manifest = JSON.parse(
|
|
59
|
+
await readFile(join(PACKAGE_ROOT, "package.json"), "utf8"),
|
|
60
|
+
) as Manifest;
|
|
61
|
+
const declared = new Set([
|
|
62
|
+
...Object.keys(manifest.dependencies ?? {}),
|
|
63
|
+
...Object.keys(manifest.peerDependencies ?? {}),
|
|
64
|
+
]);
|
|
65
|
+
const used = await builtImports();
|
|
66
|
+
|
|
67
|
+
expect([...used].filter((name) => !declared.has(name))).toEqual([]);
|
|
68
|
+
expect([...declared].filter((name) => !used.has(name))).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
});
|