@morlay/dsh-desktop-shell 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 +4 -4
- package/dist/{appconfig-BZ9wPROk.mjs → appconfig-CSZh3JG7.mjs} +2 -2
- package/dist/appconfig.d.mts +21 -0
- package/dist/appconfig.mjs +1 -2
- package/dist/{dshhome-Dp89FOr-.mjs → dshhome-DAJ9dkn2.mjs} +2 -2
- package/dist/dshhome.d.mts +6 -0
- package/dist/dshhome.mjs +1 -2
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +1050 -139
- package/dist/inline-packages.d.mts +14 -0
- package/dist/inline-packages.mjs +21 -0
- package/dist/official-BmgBsdhq.mjs +5 -0
- package/dist/official.d.mts +4 -0
- package/dist/official.mjs +1 -2
- package/dist/preload-app.cjs +5 -1
- package/dist/profile-project.d.mts +20 -0
- package/dist/profile-project.mjs +1 -2
- package/dist/{seed-BF71UAuI.mjs → seed-Bxr0BsCk.mjs} +52 -5
- package/dist/seed.d.mts +21 -0
- package/dist/seed.mjs +1 -2
- package/package.json +42 -21
- package/src/__tests__/developer-endpoint.spec.ts +17 -0
- package/src/__tests__/document-marks.spec.ts +42 -0
- package/src/__tests__/dshhome.spec.ts +108 -0
- package/src/__tests__/host-process.spec.ts +205 -0
- package/src/__tests__/ipc.spec.ts +39 -0
- package/src/__tests__/profile-project.spec.ts +204 -0
- package/src/__tests__/seed-profile.spec.ts +189 -0
- package/src/__tests__/shell-env.spec.ts +131 -0
- package/src/__tests__/shell-import-boundary.spec.ts +69 -0
- package/src/__tests__/stream-uplink.spec.ts +49 -0
- package/src/appconfig.ts +79 -0
- package/src/developer-endpoint.ts +30 -0
- package/src/developer-menu.ts +198 -0
- package/src/directory-picker.ts +37 -0
- package/src/document-marks.ts +58 -0
- package/src/dshhome.ts +29 -0
- package/src/electron.ts +33 -0
- package/src/host-process.ts +533 -0
- package/src/index.ts +716 -0
- package/src/inline-packages.ts +18 -0
- package/src/ipc.ts +58 -0
- package/src/keybindings.ts +35 -0
- package/src/official.ts +8 -0
- package/src/preload-app.ts +147 -0
- package/src/profile-project.ts +138 -0
- package/src/seed.ts +183 -0
- package/src/shell-env.ts +31 -0
- package/src/shortcuts.ts +236 -0
- package/src/stream-uplink.ts +41 -0
- package/dist/official-Cz9Mry1n.mjs +0 -167
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 壳产物里要**内联**的包(而不是留在产物外)。
|
|
3
|
+
*
|
|
4
|
+
* 壳跑在 `app.asar` 里,它的解析面只有产物自身 + Electron 提供的东西:留在外面的裸引用会变成
|
|
5
|
+
* `ERR_MODULE_NOT_FOUND`。所以这几类必须内联——
|
|
6
|
+
* - `@morlay/dsh-desktop-host`:壳直接用它(wire 协议、paths、webServer 的替身);
|
|
7
|
+
* - 快捷键桥的原生侧要用上游的「设备偏好单写者」与原子写,它们同样进不了 asar 的解析面。
|
|
8
|
+
*
|
|
9
|
+
* 这份名单是**一个事实**:壳自己的构建([`tsdown.config.ts`](../../tsdown.config.ts))与 `@local/dshdev` 的
|
|
10
|
+
* 装配(`prepareShell`)都用它——两边不一致的话,装配出来的产物就会缺依赖。
|
|
11
|
+
*/
|
|
12
|
+
export const SHELL_INLINE_PACKAGES: readonly string[] = [
|
|
13
|
+
"@morlay/dsh-desktop-host",
|
|
14
|
+
"@deepseek-ai/dsh-client-shortcuts",
|
|
15
|
+
"@deepseek-ai/dsh-atomic-write",
|
|
16
|
+
"@deepseek-ai/dsh-util-values",
|
|
17
|
+
"@deepseek-ai/dsh-util-crypto",
|
|
18
|
+
];
|
package/src/ipc.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { IpcMainInvokeEvent } from "electron";
|
|
2
|
+
|
|
3
|
+
/** 壳把宿主回环的 ws 端口经 preload 递进页面(`additionalArguments`,与 scheme 同一手法)。 */
|
|
4
|
+
export const DESKTOP_WS_PORT_ARGUMENT = "--dsh-desktop-ws-port";
|
|
5
|
+
|
|
6
|
+
export const DESKTOP_IPC = {
|
|
7
|
+
directoryPick: "dsh-desktop:directory-pick",
|
|
8
|
+
nativeThemeSet: "dsh-desktop:native-theme-set",
|
|
9
|
+
// 桌面流载体:页面把 Gateway 的流请求交给主进程,主进程用标准 Request 喂宿主。
|
|
10
|
+
streamOpen: "dsh-desktop:stream-open",
|
|
11
|
+
streamCancel: "dsh-desktop:stream-cancel",
|
|
12
|
+
// 逻辑流的上行项(客户端 → 宿主),主进程把它们写成宿主请求体的后续行。
|
|
13
|
+
streamUplink: "dsh-desktop:stream-uplink",
|
|
14
|
+
// 上行结束:主进程据此结束宿主请求体。
|
|
15
|
+
streamUplinkEnd: "dsh-desktop:stream-uplink-end",
|
|
16
|
+
streamChunk: "dsh-desktop:stream-chunk",
|
|
17
|
+
streamEnd: "dsh-desktop:stream-end",
|
|
18
|
+
streamError: "dsh-desktop:stream-error",
|
|
19
|
+
// 桌面快捷键:设备偏好的读写 / 录制状态(渲染进程 → 主进程)。
|
|
20
|
+
shortcutsGet: "dsh-desktop:shortcuts-get",
|
|
21
|
+
shortcutsEdit: "dsh-desktop:shortcuts-edit",
|
|
22
|
+
shortcutsRecording: "dsh-desktop:shortcuts-recording",
|
|
23
|
+
shortcutsCloseWindow: "dsh-desktop:shortcuts-close-window",
|
|
24
|
+
// 命中的原生按键手势(主进程 → 渲染进程);渲染侧的 native keyboard 适配器消费。
|
|
25
|
+
shortcutsInput: "dsh-desktop:shortcuts-input",
|
|
26
|
+
// 配置快照变化(主进程 → 渲染进程)。
|
|
27
|
+
shortcutsChanged: "dsh-desktop:shortcuts-changed",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
// preload 从渲染进程 argv 里读回 scheme 的参数名(主进程与 preload 共用一份)。
|
|
31
|
+
export const DESKTOP_SCHEME_ARGUMENT = "--dsh-desktop-scheme";
|
|
32
|
+
|
|
33
|
+
// 应用页面的自定义协议:取 app 工作区 package.json 的 name 派生,避免与官方桌面应用
|
|
34
|
+
// 共用同一个 scheme。去 scope 前缀、转小写、非法字符换成 `-`;派生不出合法 scheme 就报错。
|
|
35
|
+
export function desktopScheme(name: string): string {
|
|
36
|
+
const bare = name
|
|
37
|
+
.slice(name.lastIndexOf("/") + 1)
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replaceAll(/[^a-z0-9+.-]/gu, "-");
|
|
40
|
+
if (!/^[a-z][a-z0-9+.-]*$/u.test(bare))
|
|
41
|
+
throw new Error(
|
|
42
|
+
`dsh desktop: application name "${name}" does not yield a usable URL scheme (${bare})`,
|
|
43
|
+
);
|
|
44
|
+
return bare;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function assertDesktopSender(
|
|
48
|
+
event: IpcMainInvokeEvent,
|
|
49
|
+
scheme: string,
|
|
50
|
+
hostnames: readonly string[],
|
|
51
|
+
): void {
|
|
52
|
+
const senderFrame = event.senderFrame;
|
|
53
|
+
if (senderFrame === null) throw new Error("dsh desktop: rejected IPC without a sender frame");
|
|
54
|
+
const url = new URL(senderFrame.url);
|
|
55
|
+
if (url.protocol !== `${scheme}:` || !hostnames.includes(url.hostname)) {
|
|
56
|
+
throw new Error("dsh desktop: rejected IPC from an unowned renderer");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// 设备本地的快捷键偏好(Electron userData 下):原子替换、单写者协调。
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
5
|
+
import { ShortcutPersistence } from "@deepseek-ai/dsh-client-shortcuts/protocol";
|
|
6
|
+
import type {
|
|
7
|
+
ShortcutConfigSnapshot,
|
|
8
|
+
ShortcutPlatform,
|
|
9
|
+
} from "@deepseek-ai/dsh-client-shortcuts/protocol";
|
|
10
|
+
|
|
11
|
+
// 打开设备配置(原子替换),返回单写者事务协调器;`userData` 不接受渲染进程给的路径。
|
|
12
|
+
export function desktopKeybindings(
|
|
13
|
+
userData: string,
|
|
14
|
+
platform: ShortcutPlatform,
|
|
15
|
+
publish: (snapshot: ShortcutConfigSnapshot) => void,
|
|
16
|
+
): ShortcutPersistence {
|
|
17
|
+
const path = join(userData, "keybindings.json");
|
|
18
|
+
return new ShortcutPersistence(
|
|
19
|
+
{
|
|
20
|
+
read: async () => {
|
|
21
|
+
try {
|
|
22
|
+
return await readFile(path, "utf8");
|
|
23
|
+
} catch (error: unknown) {
|
|
24
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
write: (raw) => writeFileAtomic(path, raw, { mode: 0o600, dirMode: 0o700 }),
|
|
29
|
+
},
|
|
30
|
+
"desktop",
|
|
31
|
+
platform,
|
|
32
|
+
false,
|
|
33
|
+
publish,
|
|
34
|
+
);
|
|
35
|
+
}
|
package/src/official.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// 工具自带的桌面 host 变体包(不是上游 private 应用):部署里按这个名字落位。
|
|
2
|
+
export const DESKTOP_HOST_PACKAGE = "@morlay/dsh-desktop-host";
|
|
3
|
+
|
|
4
|
+
// 官方那两个 bundle:`dsh.profile.bundles` 里属于"官方侧"的名单(app 自己声明的 bundle 按它分家)。
|
|
5
|
+
export const OFFICIAL_PROFILE_BUNDLES: readonly string[] = [
|
|
6
|
+
"@deepseek-ai/dsh-base",
|
|
7
|
+
"@deepseek-ai/dsh-web-app",
|
|
8
|
+
];
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { contextBridge, ipcRenderer, webFrame } from "electron";
|
|
2
|
+
import { markDocumentPlatform, markWindowsTitlebar, syncNativeTheme } from "./document-marks.ts";
|
|
3
|
+
import { DESKTOP_IPC, DESKTOP_SCHEME_ARGUMENT, DESKTOP_WS_PORT_ARGUMENT } from "./ipc.ts";
|
|
4
|
+
import { createStreamUplink } from "./stream-uplink.ts";
|
|
5
|
+
|
|
6
|
+
interface StreamChunk {
|
|
7
|
+
readonly id: number;
|
|
8
|
+
readonly status?: number;
|
|
9
|
+
readonly data?: string;
|
|
10
|
+
readonly message?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// scheme 取自 app 名,只有主进程知道;sandboxed preload 里只能从 argv 读回。
|
|
14
|
+
const scheme = process.argv
|
|
15
|
+
.find((argument) => argument.startsWith(`${DESKTOP_SCHEME_ARGUMENT}=`))
|
|
16
|
+
?.slice(DESKTOP_SCHEME_ARGUMENT.length + 1);
|
|
17
|
+
|
|
18
|
+
// 宿主进程内回环的 ws 端口:页面把 `ws://app/…` 重写到它,就能用原生 WebSocket(垫片见
|
|
19
|
+
// `@morlay/dsh-client-modules` 的 `app-boot/desktop-ws-shim.ts`)。
|
|
20
|
+
const wsPort = process.argv
|
|
21
|
+
.find((argument) => argument.startsWith(`${DESKTOP_WS_PORT_ARGUMENT}=`))
|
|
22
|
+
?.slice(DESKTOP_WS_PORT_ARGUMENT.length + 1);
|
|
23
|
+
|
|
24
|
+
// preload 只在壳里跑(`scheme` 由壳经 argv 传来),所以"是不是桌面形态"看它就够——**不再**要求
|
|
25
|
+
// "页面在 `dsh-custom-next:` 协议下、hostname 是 `app`":窗口现在从宿主真 webServer 的 origin
|
|
26
|
+
// (`http://127.0.0.1:<port>`)载入,那个条件永远不成立,会让下面这些桌面专用注入整块失效。
|
|
27
|
+
if (scheme !== undefined && scheme !== "") {
|
|
28
|
+
// preload 脚本**跑在页面里**(浏览器没有 cordis 树,也没有壳的日志出口):这里的 console 是页面日志。
|
|
29
|
+
console.log(
|
|
30
|
+
`[dsh-shell] preload: scheme=${String(scheme)} wsPort=${String(wsPort)} webFrame=${typeof webFrame}`,
|
|
31
|
+
);
|
|
32
|
+
contextBridge.exposeInMainWorld("__DSH_DIRECTORY_PICKER__", {
|
|
33
|
+
pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise<string | null>,
|
|
34
|
+
});
|
|
35
|
+
// 页面 fetch 到自定义协议时请求体不可靠(POST 场景会挂住),桌面流改走主进程;
|
|
36
|
+
// 返回的 send / end 是逻辑流的上行(页面侧 uplink):主进程把它们写成宿主请求体的后续行。
|
|
37
|
+
contextBridge.exposeInMainWorld("__DSH_DESKTOP_STREAM__", {
|
|
38
|
+
open(
|
|
39
|
+
endpoint: string,
|
|
40
|
+
payload: unknown,
|
|
41
|
+
handlers: {
|
|
42
|
+
chunk(text: string): void;
|
|
43
|
+
end(): void;
|
|
44
|
+
fail(message: string): void;
|
|
45
|
+
},
|
|
46
|
+
): {
|
|
47
|
+
cancel(): void;
|
|
48
|
+
send(item: unknown): void;
|
|
49
|
+
end(): void;
|
|
50
|
+
} {
|
|
51
|
+
let id: number | undefined;
|
|
52
|
+
let cancelled = false;
|
|
53
|
+
const uplink = createStreamUplink(
|
|
54
|
+
(streamId, item) => {
|
|
55
|
+
ipcRenderer.send(DESKTOP_IPC.streamUplink, streamId, item);
|
|
56
|
+
},
|
|
57
|
+
(streamId) => {
|
|
58
|
+
ipcRenderer.send(DESKTOP_IPC.streamUplinkEnd, streamId);
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
const onChunk = (_event: unknown, value: StreamChunk): void => {
|
|
62
|
+
if (value.id !== id || value.data === undefined) return;
|
|
63
|
+
handlers.chunk(value.data);
|
|
64
|
+
};
|
|
65
|
+
const onEnd = (_event: unknown, value: StreamChunk): void => {
|
|
66
|
+
if (value.id === id) handlers.end();
|
|
67
|
+
};
|
|
68
|
+
const onError = (_event: unknown, value: StreamChunk): void => {
|
|
69
|
+
if (value.id === id) handlers.fail(value.message ?? "desktop stream failed");
|
|
70
|
+
};
|
|
71
|
+
ipcRenderer.on(DESKTOP_IPC.streamChunk, onChunk);
|
|
72
|
+
ipcRenderer.on(DESKTOP_IPC.streamEnd, onEnd);
|
|
73
|
+
ipcRenderer.on(DESKTOP_IPC.streamError, onError);
|
|
74
|
+
void ipcRenderer
|
|
75
|
+
.invoke(DESKTOP_IPC.streamOpen, endpoint, payload)
|
|
76
|
+
.then((opened: unknown) => {
|
|
77
|
+
id = opened as number;
|
|
78
|
+
if (cancelled) {
|
|
79
|
+
ipcRenderer.send(DESKTOP_IPC.streamCancel, id);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
uplink.bind(id);
|
|
83
|
+
})
|
|
84
|
+
.catch((error: unknown) => {
|
|
85
|
+
handlers.fail(error instanceof Error ? error.message : String(error));
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
cancel() {
|
|
89
|
+
if (cancelled) return;
|
|
90
|
+
cancelled = true;
|
|
91
|
+
ipcRenderer.off(DESKTOP_IPC.streamChunk, onChunk);
|
|
92
|
+
ipcRenderer.off(DESKTOP_IPC.streamEnd, onEnd);
|
|
93
|
+
ipcRenderer.off(DESKTOP_IPC.streamError, onError);
|
|
94
|
+
if (id !== undefined) ipcRenderer.send(DESKTOP_IPC.streamCancel, id);
|
|
95
|
+
},
|
|
96
|
+
send(item) {
|
|
97
|
+
if (!cancelled) uplink.push(item);
|
|
98
|
+
},
|
|
99
|
+
end() {
|
|
100
|
+
if (!cancelled) uplink.close();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
markDocumentPlatform(process.platform, document);
|
|
108
|
+
markWindowsTitlebar(process.platform, document);
|
|
109
|
+
|
|
110
|
+
// 上游 client 半按 `data-platform` 判定 desktop 运行时,并在那时**要求**这份桥存在
|
|
111
|
+
// (`@deepseek-ai/dsh-client-shortcuts` 的 client 半缺它就直接构造失败)。
|
|
112
|
+
contextBridge.exposeInMainWorld("dshDesktop", {
|
|
113
|
+
protocolVersion: 1,
|
|
114
|
+
keyboard: {
|
|
115
|
+
subscribe(listener: (input: unknown) => void): () => void {
|
|
116
|
+
const handle = (_event: unknown, input: unknown): void => {
|
|
117
|
+
listener(input);
|
|
118
|
+
};
|
|
119
|
+
ipcRenderer.on(DESKTOP_IPC.shortcutsInput, handle);
|
|
120
|
+
return () => {
|
|
121
|
+
ipcRenderer.off(DESKTOP_IPC.shortcutsInput, handle);
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
closeWindow: (revision: string) =>
|
|
125
|
+
ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise<void>,
|
|
126
|
+
},
|
|
127
|
+
shortcuts: {
|
|
128
|
+
get: (definitions: unknown) => ipcRenderer.invoke(DESKTOP_IPC.shortcutsGet, definitions),
|
|
129
|
+
edit: (edit: unknown, revision: string) =>
|
|
130
|
+
ipcRenderer.invoke(DESKTOP_IPC.shortcutsEdit, edit, revision),
|
|
131
|
+
subscribe(listener: (snapshot: unknown) => void): () => void {
|
|
132
|
+
const handle = (_event: unknown, snapshot: unknown): void => {
|
|
133
|
+
listener(snapshot);
|
|
134
|
+
};
|
|
135
|
+
ipcRenderer.on(DESKTOP_IPC.shortcutsChanged, handle);
|
|
136
|
+
return () => {
|
|
137
|
+
ipcRenderer.off(DESKTOP_IPC.shortcutsChanged, handle);
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
recording: (active: boolean) =>
|
|
141
|
+
ipcRenderer.invoke(DESKTOP_IPC.shortcutsRecording, active) as Promise<void>,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
syncNativeTheme(process.platform, document, (source) => {
|
|
146
|
+
ipcRenderer.send(DESKTOP_IPC.nativeThemeSet, source);
|
|
147
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Desktop profile project: the files the seed ships and the package operations the launcher runs.
|
|
2
|
+
//
|
|
3
|
+
// 种子里只带 app 自己的 bundle(`file:` 指向随包 `vendor/` 副本);官方包与 dsh 来自不可变 runtime,
|
|
4
|
+
// profile 对它们的依赖经 `overrides` 用 `link:` 指回 runtime——安装因此不需要 registry。
|
|
5
|
+
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
// Profile pnpm settings file the launcher appends run-time `overrides` to.
|
|
11
|
+
export const PROFILE_WORKSPACE_NAME = "pnpm-workspace.yaml";
|
|
12
|
+
|
|
13
|
+
// Seed-written report naming the runtime packages the profile resolves through `overrides`.
|
|
14
|
+
export const PROFILE_RUNTIME_REPORT_NAME = "desktop-runtime-packages.json";
|
|
15
|
+
|
|
16
|
+
// Profile directory holding the `file:` sources of the profile's own bundles.
|
|
17
|
+
export const PROFILE_VENDOR_DIR_NAME = "vendor";
|
|
18
|
+
|
|
19
|
+
// One package the profile dependency graph links to the installed runtime.
|
|
20
|
+
export interface RuntimeOverride {
|
|
21
|
+
// Package name as it appears in the profile's dependency graph.
|
|
22
|
+
readonly name: string;
|
|
23
|
+
// Absolute directory inside the installed runtime holding that package.
|
|
24
|
+
readonly target: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const TOP_LEVEL_KEY = /^([A-Za-z_][A-Za-z0-9_-]*):(?:[ \t]|$)/u;
|
|
28
|
+
|
|
29
|
+
// Read the seed's runtime package report and point each entry at the installed runtime: one `link:`
|
|
30
|
+
// override per reported package, in report order. Throws when the report is missing, malformed, or
|
|
31
|
+
// holds an invalid entry.
|
|
32
|
+
export async function runtimeOverrides(
|
|
33
|
+
profileDir: string,
|
|
34
|
+
runtimeDir: string,
|
|
35
|
+
): Promise<RuntimeOverride[]> {
|
|
36
|
+
const report = JSON.parse(
|
|
37
|
+
await readFile(join(profileDir, PROFILE_RUNTIME_REPORT_NAME), "utf8"),
|
|
38
|
+
) as { runtimePackages?: unknown };
|
|
39
|
+
const entries = report.runtimePackages;
|
|
40
|
+
if (!Array.isArray(entries) || !entries.every(isRuntimePackage)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`dsh desktop: ${PROFILE_RUNTIME_REPORT_NAME} has an invalid runtimePackages list`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return entries.map((entry) => ({
|
|
46
|
+
name: entry.name,
|
|
47
|
+
target: join(runtimeDir, ...entry.path.split("/")),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// One reported runtime package: its dependency name and its runtime-relative directory.
|
|
52
|
+
function isRuntimePackage(
|
|
53
|
+
value: unknown,
|
|
54
|
+
): value is { readonly name: string; readonly path: string } {
|
|
55
|
+
if (typeof value !== "object" || value === null) return false;
|
|
56
|
+
const candidate = value as { name?: unknown; path?: unknown };
|
|
57
|
+
return (
|
|
58
|
+
typeof candidate.name === "string" &&
|
|
59
|
+
candidate.name !== "" &&
|
|
60
|
+
typeof candidate.path === "string" &&
|
|
61
|
+
candidate.path !== ""
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Write the runtime overrides into profile pnpm settings, replacing an earlier generation; an empty
|
|
66
|
+
// override list leaves the file untouched. Returns the settings text to write.
|
|
67
|
+
export function workspaceWithOverrides(
|
|
68
|
+
text: string,
|
|
69
|
+
overrides: readonly RuntimeOverride[],
|
|
70
|
+
): string {
|
|
71
|
+
if (overrides.length === 0) return text;
|
|
72
|
+
const kept: string[] = [];
|
|
73
|
+
let skipping = false;
|
|
74
|
+
for (const line of text.replaceAll("\r\n", "\n").split("\n")) {
|
|
75
|
+
const key = TOP_LEVEL_KEY.exec(line)?.[1];
|
|
76
|
+
if (key !== undefined) skipping = key === "overrides";
|
|
77
|
+
if (!skipping) kept.push(line);
|
|
78
|
+
}
|
|
79
|
+
while (kept.length > 0 && (kept[kept.length - 1] ?? "").trim() === "") kept.pop();
|
|
80
|
+
const block = [
|
|
81
|
+
"overrides:",
|
|
82
|
+
...overrides.map(
|
|
83
|
+
(entry) => ` ${JSON.stringify(entry.name)}: ${JSON.stringify(`link:${entry.target}`)}`,
|
|
84
|
+
),
|
|
85
|
+
];
|
|
86
|
+
return `${[...kept, ...block].join("\n")}\n`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Injection point for the profile installation process.
|
|
90
|
+
export type ProfileInstallSpawn = typeof spawn;
|
|
91
|
+
|
|
92
|
+
// Inputs for one offline profile installation.
|
|
93
|
+
export interface ProfileInstallOptions {
|
|
94
|
+
// Bundled Node.js executable running pnpm.
|
|
95
|
+
readonly node: string;
|
|
96
|
+
// Bundled pnpm entry (`.mjs`) inside the runtime.
|
|
97
|
+
readonly pnpmEntry: string;
|
|
98
|
+
// Profile directory whose dependencies are installed.
|
|
99
|
+
readonly profileDir: string;
|
|
100
|
+
// Process launcher, injectable for tests.
|
|
101
|
+
readonly spawn?: ProfileInstallSpawn;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Install the profile's own bundles offline from the seed's `file:` sources. Throws when pnpm cannot
|
|
105
|
+
// start or exits non-zero, with its diagnostics tail.
|
|
106
|
+
export function installProfile(options: ProfileInstallOptions): Promise<void> {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const spawnChild = options.spawn ?? spawn;
|
|
109
|
+
const child = spawnChild(
|
|
110
|
+
options.node,
|
|
111
|
+
[options.pnpmEntry, "install", "--prod", "--ignore-scripts", "--offline"],
|
|
112
|
+
{ cwd: options.profileDir, stdio: ["ignore", "pipe", "pipe"] },
|
|
113
|
+
);
|
|
114
|
+
let stderr = "";
|
|
115
|
+
child.stderr?.setEncoding("utf8");
|
|
116
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
117
|
+
stderr = (stderr + chunk).slice(-4096);
|
|
118
|
+
});
|
|
119
|
+
// 消费 stdout,避免管道回压把 pnpm 卡在写日志上。
|
|
120
|
+
child.stdout?.on("data", () => undefined);
|
|
121
|
+
child.once("error", (error) => {
|
|
122
|
+
reject(error);
|
|
123
|
+
});
|
|
124
|
+
child.once("close", (code) => {
|
|
125
|
+
if (code === 0) {
|
|
126
|
+
resolve();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const diagnostic = stderr.trim();
|
|
130
|
+
reject(
|
|
131
|
+
new Error(
|
|
132
|
+
`dsh desktop: profile install exited with ${String(code)}` +
|
|
133
|
+
`${diagnostic === "" ? "" : `: ${diagnostic}`}`,
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
package/src/seed.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
chmod,
|
|
6
|
+
copyFile,
|
|
7
|
+
lstat,
|
|
8
|
+
mkdir,
|
|
9
|
+
readdir,
|
|
10
|
+
readFile,
|
|
11
|
+
readlink,
|
|
12
|
+
rm,
|
|
13
|
+
symlink,
|
|
14
|
+
unlink,
|
|
15
|
+
writeFile,
|
|
16
|
+
} from "node:fs/promises";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { PROFILE_NAME, PROFILE_PATCH_NAME } from "./appconfig.ts";
|
|
19
|
+
|
|
20
|
+
export const SEED_DIR_NAME = "dsh-home";
|
|
21
|
+
|
|
22
|
+
export const SEED_HASH_NAME = ".seed-hash";
|
|
23
|
+
|
|
24
|
+
// Seed-relative directory holding the immutable runtime: the host's dsh installation and the Web frontend.
|
|
25
|
+
export const SEED_RUNTIME_DIR_NAME = "runtime";
|
|
26
|
+
|
|
27
|
+
const SEED_SKIP_DIRS = new Set([".nub-store", ".store", ".nub"]);
|
|
28
|
+
|
|
29
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
30
|
+
try {
|
|
31
|
+
await access(path);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readSeedHash(path: string): Promise<string> {
|
|
39
|
+
try {
|
|
40
|
+
return await readFile(path, "utf8");
|
|
41
|
+
} catch {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
47
|
+
try {
|
|
48
|
+
return (await lstat(path)).isDirectory();
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 解压随包的依赖归档:实体文件、闭包完整(替代逐包 `link:` 那种写法)。 */
|
|
55
|
+
async function extractProfileModules(archive: string, profileDir: string): Promise<void> {
|
|
56
|
+
await new Promise<void>((done, fail) => {
|
|
57
|
+
const child = spawn("tar", ["-xzf", archive, "-C", profileDir], {
|
|
58
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
59
|
+
});
|
|
60
|
+
child.on("error", fail);
|
|
61
|
+
child.on("close", (code) =>
|
|
62
|
+
code === 0 ? done() : fail(new Error(`tar -xzf ${archive} exited with ${String(code)}`)),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const PROFILE_MODULES_ARCHIVE_NAME = "profile-node-modules.tar";
|
|
68
|
+
|
|
69
|
+
/** 一次"种 profile"的结果:调用方据此记录、并在打包形态把 `no-seed` 当致命。 */
|
|
70
|
+
export type SeedOutcome = "seeded" | "up-to-date" | "no-seed";
|
|
71
|
+
|
|
72
|
+
/** 依赖面是否真的在:`node_modules` 存在且非空。 */
|
|
73
|
+
async function hasDependencies(profileDir: string): Promise<boolean> {
|
|
74
|
+
const modules = join(profileDir, "node_modules");
|
|
75
|
+
if (!(await isDirectory(modules))) return false;
|
|
76
|
+
return (await readdir(modules)).length > 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 把随包的种子种成一份可用的 profile:清单 + 各 bundle 自己的 patch 从种子拷(用户那份 patch 除外),
|
|
81
|
+
* **依赖面**由随包归档解压而来。种完才算数——调用方要等它返回才起宿主。
|
|
82
|
+
*
|
|
83
|
+
* 判"已是最新"要同时满足:指纹一致 **且** 依赖面真的在(归档在场时)。指纹只说明"清单与闭包是哪一份",
|
|
84
|
+
* 不说明"它被解压出来了":少了后半句,一次解压失败(或谁清过 `node_modules`)就永远补不回来,宿主会一直
|
|
85
|
+
* 带着残缺 profile 起来。
|
|
86
|
+
* @param seedDir - 随包的种子根(`Resources/seed`)。
|
|
87
|
+
* @param home - 数据面根(`$DSH_HOME`);profile 落在 `<home>/profiles/<name>`。
|
|
88
|
+
* @returns 种了(`seeded`)、已是最新(`up-to-date`)、或随包根本没有这份种(`no-seed`)。
|
|
89
|
+
* @throws 归档解压失败、或解压后没有依赖面时。
|
|
90
|
+
*/
|
|
91
|
+
export async function ensureSeedProfile(seedDir: string, home: string): Promise<SeedOutcome> {
|
|
92
|
+
const seedProfile = join(seedDir, "profiles", PROFILE_NAME);
|
|
93
|
+
if (!(await isDirectory(seedProfile))) return "no-seed";
|
|
94
|
+
const profileDir = join(home, "profiles", PROFILE_NAME);
|
|
95
|
+
const archive = join(seedDir, PROFILE_MODULES_ARCHIVE_NAME);
|
|
96
|
+
const hasArchive = await pathExists(archive);
|
|
97
|
+
const seedHash = await readSeedHash(join(seedProfile, SEED_HASH_NAME));
|
|
98
|
+
if (seedHash !== "") {
|
|
99
|
+
try {
|
|
100
|
+
const stat = await lstat(profileDir);
|
|
101
|
+
if (
|
|
102
|
+
!stat.isSymbolicLink() &&
|
|
103
|
+
(await readSeedHash(join(profileDir, SEED_HASH_NAME))) === seedHash &&
|
|
104
|
+
(!hasArchive || (await hasDependencies(profileDir)))
|
|
105
|
+
) {
|
|
106
|
+
return "up-to-date";
|
|
107
|
+
}
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
// profile 的 patch 文档是用户数据(settings 面板写在那里):重种换的是装配面,
|
|
111
|
+
// 这一份先读出来、种完再放回去,升级不会把用户的设置带走。
|
|
112
|
+
const patchPath = join(profileDir, PROFILE_PATCH_NAME);
|
|
113
|
+
const userPatch = await readFile(patchPath, "utf8").catch(() => undefined);
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const stat = await lstat(profileDir);
|
|
117
|
+
if (stat.isSymbolicLink()) await unlink(profileDir);
|
|
118
|
+
else if (stat.isDirectory()) await rm(profileDir, { recursive: true });
|
|
119
|
+
else await unlink(profileDir);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
122
|
+
}
|
|
123
|
+
await copySeed(seedProfile, profileDir);
|
|
124
|
+
|
|
125
|
+
// **profile 的依赖面**:随包那份归档解压进来(实体文件、闭包完整)。历史做法是壳按载荷清单逐包
|
|
126
|
+
// `link:`——`link` 不带传递依赖(上千个包会丢),路径还指向构建机(`.pnpm/<pkg>@file++++Users+…`)。
|
|
127
|
+
if (hasArchive) {
|
|
128
|
+
await rm(join(profileDir, "node_modules"), { recursive: true, force: true });
|
|
129
|
+
await extractProfileModules(archive, profileDir);
|
|
130
|
+
// **解压之后自检**:tar 退出码 0 不等于"闭包真的落地了";种出来的东西不完整就该当场报,
|
|
131
|
+
// 而不是让宿主带着残缺 profile 起一次(那只能表现为"组件不完整")。
|
|
132
|
+
if (!(await hasDependencies(profileDir))) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`dsh desktop: ${archive} did not yield a dependency closure at ${join(profileDir, "node_modules")}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (userPatch !== undefined) {
|
|
139
|
+
await mkdir(dirname(patchPath), { recursive: true });
|
|
140
|
+
await writeFile(patchPath, userPatch);
|
|
141
|
+
}
|
|
142
|
+
return "seeded";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Plant one profile subtree. The seed's runtime lives beside the profile and stays in the
|
|
146
|
+
// application's read-only resources, so only `profiles/<name>` is copied into the home.
|
|
147
|
+
async function copySeed(source: string, target: string): Promise<void> {
|
|
148
|
+
await mkdir(target, { recursive: true });
|
|
149
|
+
const visit = async (directory: string, prefix: string): Promise<void> => {
|
|
150
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
151
|
+
const path = join(directory, entry.name);
|
|
152
|
+
const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
153
|
+
const destination = join(target, ...relativePath.split("/"));
|
|
154
|
+
if (entry.isSymbolicLink()) {
|
|
155
|
+
if (await pathExists(destination)) continue;
|
|
156
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
157
|
+
await symlink(
|
|
158
|
+
await readlink(path),
|
|
159
|
+
destination,
|
|
160
|
+
process.platform === "win32" ? "junction" : "dir",
|
|
161
|
+
);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
let stat: Stats;
|
|
165
|
+
try {
|
|
166
|
+
stat = await lstat(path);
|
|
167
|
+
} catch {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (stat.isDirectory()) {
|
|
171
|
+
if (SEED_SKIP_DIRS.has(entry.name)) continue;
|
|
172
|
+
await mkdir(destination, { recursive: true });
|
|
173
|
+
await visit(path, relativePath);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!stat.isFile() || (await pathExists(destination))) continue;
|
|
177
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
178
|
+
await copyFile(path, destination);
|
|
179
|
+
await chmod(destination, stat.mode & 0o777);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
await visit(source, "");
|
|
183
|
+
}
|
package/src/shell-env.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function rcFileFor(shell: string): string {
|
|
5
|
+
switch (basename(shell)) {
|
|
6
|
+
case "bash":
|
|
7
|
+
return "~/.bashrc";
|
|
8
|
+
case "zsh":
|
|
9
|
+
return "~/.zshrc";
|
|
10
|
+
default:
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function shellQuote(value: string): string {
|
|
16
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function shellWrappedSpawn(
|
|
20
|
+
command: string,
|
|
21
|
+
args: readonly string[],
|
|
22
|
+
options: SpawnOptions,
|
|
23
|
+
): ChildProcess {
|
|
24
|
+
const shell = process.env.SHELL;
|
|
25
|
+
const rc = shell === undefined || shell === "" ? "" : rcFileFor(shell);
|
|
26
|
+
if (rc !== "" && process.platform !== "win32" && shell !== undefined) {
|
|
27
|
+
const commandLine = `source ${rc} >/dev/null 2>&1; exec ${shellQuote(command)} ${args.map(shellQuote).join(" ")}`;
|
|
28
|
+
return spawn(shell, ["-c", commandLine], { ...options, detached: true });
|
|
29
|
+
}
|
|
30
|
+
return spawn(command, [...args], { ...options, detached: process.platform !== "win32" });
|
|
31
|
+
}
|