@morlay/dsh-desktop-shell 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.
Files changed (51) hide show
  1. package/README.md +4 -4
  2. package/dist/{appconfig-BZ9wPROk.mjs → appconfig-CSZh3JG7.mjs} +2 -2
  3. package/dist/appconfig.d.mts +21 -0
  4. package/dist/appconfig.mjs +1 -2
  5. package/dist/{dshhome-Dp89FOr-.mjs → dshhome-DAJ9dkn2.mjs} +2 -2
  6. package/dist/dshhome.d.mts +6 -0
  7. package/dist/dshhome.mjs +1 -2
  8. package/dist/index.d.mts +6 -0
  9. package/dist/index.mjs +1050 -139
  10. package/dist/inline-packages.d.mts +14 -0
  11. package/dist/inline-packages.mjs +21 -0
  12. package/dist/official-BmgBsdhq.mjs +5 -0
  13. package/dist/official.d.mts +4 -0
  14. package/dist/official.mjs +1 -2
  15. package/dist/preload-app.cjs +5 -1
  16. package/dist/profile-project.d.mts +20 -0
  17. package/dist/profile-project.mjs +1 -2
  18. package/dist/{seed-BF71UAuI.mjs → seed-Bxr0BsCk.mjs} +52 -5
  19. package/dist/seed.d.mts +21 -0
  20. package/dist/seed.mjs +1 -2
  21. package/package.json +44 -22
  22. package/src/__tests__/developer-endpoint.spec.ts +17 -0
  23. package/src/__tests__/document-marks.spec.ts +42 -0
  24. package/src/__tests__/dshhome.spec.ts +108 -0
  25. package/src/__tests__/host-process.spec.ts +205 -0
  26. package/src/__tests__/ipc.spec.ts +39 -0
  27. package/src/__tests__/profile-project.spec.ts +204 -0
  28. package/src/__tests__/seed-profile.spec.ts +189 -0
  29. package/src/__tests__/shell-env.spec.ts +131 -0
  30. package/src/__tests__/shell-import-boundary.spec.ts +69 -0
  31. package/src/__tests__/stream-uplink.spec.ts +49 -0
  32. package/src/appconfig.ts +79 -0
  33. package/src/developer-endpoint.ts +30 -0
  34. package/src/developer-menu.ts +198 -0
  35. package/src/directory-picker.ts +37 -0
  36. package/src/document-marks.ts +58 -0
  37. package/src/dshhome.ts +29 -0
  38. package/src/electron.ts +33 -0
  39. package/src/host-process.ts +533 -0
  40. package/src/index.ts +716 -0
  41. package/src/inline-packages.ts +18 -0
  42. package/src/ipc.ts +58 -0
  43. package/src/keybindings.ts +35 -0
  44. package/src/official.ts +8 -0
  45. package/src/preload-app.ts +147 -0
  46. package/src/profile-project.ts +138 -0
  47. package/src/seed.ts +183 -0
  48. package/src/shell-env.ts +31 -0
  49. package/src/shortcuts.ts +236 -0
  50. package/src/stream-uplink.ts +41 -0
  51. package/dist/official-Cz9Mry1n.mjs +0 -167
package/src/index.ts ADDED
@@ -0,0 +1,716 @@
1
+ import { access, appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { format } from "node:util";
5
+ import { app, BrowserWindow, dialog, ipcMain, nativeTheme, protocol } from "./electron.ts";
6
+
7
+ declare global {
8
+ /** 壳的包根:由引导(`bootstrap.mjs`,它就在包根)写入;发布包里没有引导时为 undefined。 */
9
+ // eslint-disable-next-line no-var -- 引导与业务代码之间的约定。
10
+ var __DSH_SHELL_ROOT__: string | undefined;
11
+ }
12
+
13
+ import type { BrowserWindow as BrowserWindowType, BrowserWindowConstructorOptions } from "electron";
14
+ import { DESKTOP_HOST_PACKAGE } from "./official.ts";
15
+ import { PRELOAD_SOURCE_PATH } from "@morlay/dsh-desktop-host/preload-source";
16
+ import { loadAppConfig, PROFILE_NAME, type AppConfig } from "./appconfig.ts";
17
+ import { installDesktopDirectoryPicker } from "./directory-picker.ts";
18
+ import { installDeveloperMenu } from "./developer-menu.ts";
19
+ import { resolveDshHome } from "./dshhome.ts";
20
+ import { DesktopHostProcess, type DesktopHostReady } from "./host-process.ts";
21
+ import { DESKTOP_IPC, DESKTOP_SCHEME_ARGUMENT, assertDesktopSender, desktopScheme } from "./ipc.ts";
22
+ import {} from "./profile-project.ts";
23
+ import { SEED_RUNTIME_DIR_NAME, ensureSeedProfile } from "./seed.ts";
24
+ import { installDesktopShortcuts } from "./shortcuts.ts";
25
+ import {
26
+ DESKTOP_STREAM_PATH,
27
+ encodeDesktopStreamItem,
28
+ encodeDesktopStreamOpen,
29
+ } from "@morlay/dsh-desktop-host/wire";
30
+ import { shellWrappedSpawn } from "./shell-env.ts";
31
+
32
+ let focusPrimaryWindow = (): void => {};
33
+
34
+ // appconfig.json 的位置:打包形态只看 resources,dev 形态由 CLI 指到构建目录。
35
+ /** app 目录(dev 是装配目录、打包是 `Resources/app`):由引导写进全局。 */
36
+ function shellRoot(): string {
37
+ const root = globalThis.__DSH_SHELL_ROOT__;
38
+ if (root === undefined || root === "") {
39
+ throw new Error("dsh desktop: the shell root is missing (bootstrap did not report it)");
40
+ }
41
+ return root;
42
+ }
43
+
44
+ function appConfigDir(): string {
45
+ const configured =
46
+ developmentLayout() === undefined ? undefined : join(shellRoot(), "..", "runtime");
47
+ return app.isPackaged || configured === undefined || configured === ""
48
+ ? process.resourcesPath
49
+ : configured;
50
+ }
51
+
52
+ function startupFailure(error: unknown): never {
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ void shellLog("入口失败或收尾失败 %o", error);
55
+ void shellLog(`启动失败:${message}`);
56
+ dialog.showErrorBox("dsh desktop startup failed", message);
57
+ app.exit(1);
58
+ throw error instanceof Error ? error : new Error(message);
59
+ }
60
+
61
+ // 配置与协议必须在 `ready` 之前定下(`registerSchemesAsPrivileged` 只有这一个窗口期):
62
+ // Electron 会等入口模块的顶层 await 跑完才 emit `ready`,所以这里用 await 而不是同步 IO。
63
+ const appConfig: AppConfig = await loadAppConfig(appConfigDir()).catch((error: unknown) =>
64
+ startupFailure(error),
65
+ );
66
+
67
+ // 自定义协议取自 app 名:与官方桌面应用(`dsh-app`)错开。
68
+ const SCHEME = ((): string => {
69
+ try {
70
+ return desktopScheme(appConfig.name);
71
+ } catch (error) {
72
+ startupFailure(error);
73
+ }
74
+ })();
75
+
76
+ protocol.registerSchemesAsPrivileged([
77
+ {
78
+ scheme: SCHEME,
79
+ privileges: {
80
+ standard: true,
81
+ secure: true,
82
+ supportFetchAPI: true,
83
+ corsEnabled: true,
84
+ stream: true,
85
+ codeCache: true,
86
+ },
87
+ },
88
+ ]);
89
+
90
+ // Windows 自绘标题栏高度(DIP),与 preload 写入的 CSS 变量一致。
91
+ const WINDOWS_TITLEBAR_HEIGHT = 40;
92
+
93
+ interface RuntimeResources {
94
+ readonly node: string;
95
+ readonly runtime: string;
96
+ readonly pnpm: string;
97
+ readonly nodeBin: string;
98
+ readonly seed: string;
99
+ // 随包的 TS loader:源码面下 host 进程用它转译上游(`exports` 指 `src`),也交给它拉起的子进程。
100
+ readonly loader: string;
101
+ }
102
+
103
+ /**
104
+ * 打包形态下"当 node 用"的可执行文件。
105
+ *
106
+ * macOS 上 spawn 主二进制(`process.execPath`)会让系统**多出一个叫 `exec` 的 Dock 图标**——它落在 `.app`
107
+ * 里、被当成 GUI 应用注册。改用同 bundle 的 **Helper**(它的 `Info.plist` 带 `LSUIElement`)就没有这个图标,
108
+ * 而 `ELECTRON_RUN_AS_NODE` 对它一样生效。其余平台/形态直接用 `process.execPath`。
109
+ * @returns 可执行文件的绝对路径。
110
+ */
111
+ async function nodeExecutableOf(): Promise<string> {
112
+ if (process.platform === "darwin") {
113
+ const app = basename(process.execPath);
114
+ const helper = join(
115
+ dirname(process.execPath),
116
+ "..",
117
+ "Frameworks",
118
+ `${app} Helper.app`,
119
+ "Contents",
120
+ "MacOS",
121
+ `${app} Helper`,
122
+ );
123
+ if (await pathExistsOf(helper)) return helper;
124
+ }
125
+ return process.execPath;
126
+ }
127
+
128
+ async function runtimeResources(): Promise<RuntimeResources> {
129
+ // 两种形态的"随包载荷"位置都能自己推出来:
130
+ // - 打包:`Resources/{runtime,seed}`;
131
+ // - dev:壳跑在装配目录 `<buildCache>/shell`,于是同级的 `../{runtime,seed}`——工具因此**不需要**把这些
132
+ // 路径注入进来(dev 的宿主用系统 node,PATH 上就有)。
133
+ const resourcesDir =
134
+ developmentLayout() === undefined ? process.resourcesPath : join(shellRoot(), "..");
135
+ // 宿主的 node:打包形态用 **Electron 的二进制**(上游同款:`main.ts` 的 `const node = process.execPath`
136
+ // 与 `node-environment.ts` 的 `ELECTRON_RUN_AS_NODE`)。随包因此不再需要一份 node;而且宿主也能读 asar
137
+ // (asar 补丁在 `ELECTRON_RUN_AS_NODE` 下照样生效)——app 才能整块打进 asar。dev 形态用系统 node。
138
+ const node = developmentLayout() === undefined ? await nodeExecutableOf() : "node";
139
+ const seed = join(resourcesDir, "seed");
140
+ return {
141
+ node,
142
+ seed,
143
+ // 随包运行时与 pnpm 只在打包产物里存在;dev 形态从工作区解析依赖。
144
+ runtime:
145
+ developmentLayout() === undefined
146
+ ? join(resourcesDir, "runtime")
147
+ : join(seed, SEED_RUNTIME_DIR_NAME),
148
+ pnpm: join(resourcesDir, "runtime", "pnpm", "bin", "pnpm.mjs"),
149
+ nodeBin: join(resourcesDir, "runtime", "bin"),
150
+ // TS 运行时入口:**按路径取随包运行时里的那一份**(工具在备 runtime 时把 `@morlay/dsh-oxc-node` 的
151
+ // `register.mjs` 连同它的依赖层拷进 `<runtime>/loader`)。壳因此**不依赖**那个包——依赖方向是
152
+ // "devpackages → packages",`@local/*` 不落到发布包的运行时依赖上。
153
+ loader: join(resourcesDir, "runtime", "loader", "register.mjs"),
154
+ };
155
+ }
156
+
157
+ // dev 形态的两格:profile root(app 目录本身)与安装根(官方闭包、前端产物与 host 载荷的来源)。
158
+ // 打包形态两格分别由 `$DSH_HOME/profiles/<name>` 与随包 runtime 给出。
159
+ interface DevelopmentLayout {
160
+ readonly profileDir: string;
161
+ readonly runtimeDir: string;
162
+ }
163
+
164
+ function configuredPath(value: string | undefined): string | undefined {
165
+ return value === undefined || value === "" ? undefined : value;
166
+ }
167
+
168
+ function developmentLayout(): DevelopmentLayout | undefined {
169
+ const profileDir = configuredPath(process.env.DSH_DESKTOP_DEV_PROFILE_DIR);
170
+ const runtimeDir = configuredPath(process.env.DSH_DESKTOP_DEV_RUNTIME_DIR);
171
+ if (profileDir === undefined && runtimeDir === undefined) return undefined;
172
+ if (app.isPackaged)
173
+ throw new Error("dsh desktop: the development layout is unavailable in packaged applications");
174
+ if (profileDir === undefined || runtimeDir === undefined)
175
+ throw new Error(
176
+ "dsh desktop: DSH_DESKTOP_DEV_PROFILE_DIR and DSH_DESKTOP_DEV_RUNTIME_DIR must be set together",
177
+ );
178
+ return { profileDir: resolve(profileDir), runtimeDir: resolve(runtimeDir) };
179
+ }
180
+
181
+ // 窗口形态:macOS 走 sidebar vibrancy + hiddenInset(交通灯落在侧边栏内),Windows 自绘 caption。
182
+ function windowFrame(): Pick<
183
+ BrowserWindowConstructorOptions,
184
+ | "titleBarStyle"
185
+ | "frame"
186
+ | "titleBarOverlay"
187
+ | "trafficLightPosition"
188
+ | "vibrancy"
189
+ | "visualEffectState"
190
+ | "backgroundColor"
191
+ > {
192
+ if (process.platform === "darwin") {
193
+ return {
194
+ titleBarStyle: "hiddenInset",
195
+ trafficLightPosition: { x: 16, y: 18 },
196
+ vibrancy: "sidebar",
197
+ // 'active' 让失焦时的材质稳定;'followWindow' 会把侧边栏洗白。
198
+ visualEffectState: "active",
199
+ backgroundColor: "#00000000",
200
+ };
201
+ }
202
+ if (process.platform === "win32") {
203
+ return {
204
+ titleBarStyle: "hidden",
205
+ titleBarOverlay: {
206
+ height: WINDOWS_TITLEBAR_HEIGHT,
207
+ color: nativeTheme.shouldUseDarkColors ? "#1b1b1c" : "#f9fafb",
208
+ symbolColor: nativeTheme.shouldUseDarkColors ? "#f9fafb" : "#0f1115",
209
+ },
210
+ };
211
+ }
212
+ return { frame: false };
213
+ }
214
+
215
+ function createWindow(
216
+ preload: string,
217
+ config: { width: number; height: number; minWidth: number; minHeight: number },
218
+ ): BrowserWindowType {
219
+ const window = new BrowserWindow({
220
+ width: config.width,
221
+ height: config.height,
222
+ minWidth: config.minWidth,
223
+ minHeight: config.minHeight,
224
+ show: false,
225
+ ...windowFrame(),
226
+ webPreferences: {
227
+ preload,
228
+ nodeIntegration: false,
229
+ contextIsolation: true,
230
+ sandbox: true,
231
+ webSecurity: true,
232
+ // preload 是 sandboxed 的(读不到文件),scheme 只能这样递进去。
233
+ additionalArguments: [
234
+ `${DESKTOP_SCHEME_ARGUMENT}=${SCHEME}`,
235
+ // 页面把它当作 `ws://app/…` 的落点(见 preload 的 `__DSH_DESKTOP_WS__`)。
236
+ ],
237
+ },
238
+ });
239
+ window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
240
+ window.webContents.on("will-navigate", (event, url) => {
241
+ const destination = new URL(url);
242
+ if (destination.protocol !== `${SCHEME}:`) event.preventDefault();
243
+ });
244
+ return window;
245
+ }
246
+
247
+ function developmentHostInspectPort(enabled: boolean): number | undefined {
248
+ const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT;
249
+ if (!enabled || configured === undefined || configured === "") return undefined;
250
+ const port = Number(configured);
251
+ // 0 也要放行:它表示"向系统要一个"(dshdev 的缺省就是 0,见 `debugPort`)。
252
+ if (!Number.isSafeInteger(port) || port < 0 || port > 65_535) {
253
+ throw new Error(
254
+ "dsh desktop: DSH_DESKTOP_HOST_INSPECT_PORT must be an integer from 0 through 65535",
255
+ );
256
+ }
257
+ return port;
258
+ }
259
+
260
+ // host 进程自己那份转译器(绝对路径——host 的 cwd 是部署 / profile 目录,裸名在那儿解析不到)。
261
+ function sourceLoader(resources: RuntimeResources): string {
262
+ return resources.loader;
263
+ }
264
+
265
+ // 拉起的子进程(上游 subprocess runner 等)走同一份转译器:它们按源码面加载上游入口,
266
+ // 而 argv 里的 `--import` 只有自己那份,所以要经环境把选择传下去。
267
+ function sourceLoaderEnvironment(path: string): Record<string, string> {
268
+ return { DSH_SOURCE_LOADER: path };
269
+ }
270
+
271
+ /**
272
+ * 壳的日志。
273
+ *
274
+ * 打包形态的 Electron **不把 `console` 写进 stdout**(它进系统日志),出了事只看到"窗口没起来"。
275
+ * 所以壳自己的关键节点(启动、宿主就绪、fatal)落一份文件日志,与宿主那份(`${DSH_HOME}/logs/dev-host.log`,
276
+ * 由 `@morlay/dsh-client-modules` 写)同目录,两边对着看就能定位"页面 → 壳 → 宿主"断在哪一段。
277
+ */
278
+ async function shellLog(message: string, ...args: readonly unknown[]): Promise<void> {
279
+ const home = resolveDshHome(appConfig) ?? join(process.cwd(), ".dsh-store");
280
+ const file = join(home, "logs", "shell.log");
281
+ try {
282
+ await mkdir(dirname(file), { recursive: true });
283
+ await appendFile(file, `${new Date().toISOString()} ${format(message, ...args)}\n`, "utf8");
284
+ } catch {
285
+ // 记不下来就算了:这里已在异常路径上,不该再抛。
286
+ }
287
+ }
288
+
289
+ /** 文件在不在(项目禁同步 IO)。 */
290
+ async function pathExistsOf(path: string): Promise<boolean> {
291
+ return await access(path).then(
292
+ () => true,
293
+ () => false,
294
+ );
295
+ }
296
+
297
+ async function main(): Promise<void> {
298
+ const development = developmentLayout();
299
+ const resources = await runtimeResources();
300
+ const hostInspectPort = developmentHostInspectPort(development !== undefined);
301
+ const profileDir =
302
+ development?.profileDir ?? join(resolveDshHome(appConfig) ?? "", "profiles", PROFILE_NAME);
303
+
304
+ const runtimeDir = development?.runtimeDir ?? resources.runtime;
305
+ const dataDir = resolveDshHome(appConfig);
306
+ // bundles 的真值在 profile 的清单里(`dsh.profile.bundles`)——壳不持有这份列表,读一次只为日志。
307
+ const bundleCount = await readFile(join(profileDir, "package.json"), "utf8")
308
+ .then(
309
+ (text) =>
310
+ (JSON.parse(text) as { dsh?: { profile?: { bundles?: unknown[] } } }).dsh?.profile?.bundles
311
+ ?.length,
312
+ )
313
+ .catch(() => undefined);
314
+ // **一行说清启动面**:形态 / 根 / profileDir / 数据面 / bundles 数。打包形态出问题(profile 路径、
315
+ // bundle 配置没生效、少服务)时,这一行就是第一现场——以前只有"形态 + 根",路径对不对全靠猜。
316
+ await shellLog(
317
+ `启动:形态=${development === undefined ? "bundle" : "dev"} root=${globalThis.__DSH_SHELL_ROOT__ ?? "(未知)"} ` +
318
+ `profileDir=${profileDir} dataDir=${dataDir ?? "(未解析)"} dataDirSpec=${appConfig.dataDir} ` +
319
+ `bundles=${String(bundleCount)}`,
320
+ );
321
+
322
+ if (development === undefined) {
323
+ if (dataDir === undefined)
324
+ throw new Error("dsh desktop: packaged applications require a concrete dataDir");
325
+ // **先完整种好 profile 再起宿主**:`ensureSeedProfile` 返回时,清单与依赖面(随包归档解压出来的
326
+ // 实体闭包)都已就位——它自己会校验"依赖面是否真的落地",缺了就当场抛。"种了什么"也要看得见:
327
+ // 打包形态的 profile 出了问题(组件不完整之类)第一件事就是看这一行。随包没有那份种则是致命的:
328
+ // 那说明产物本身就缺件,不能带着空 profile 硬起。
329
+ const seeded = await ensureSeedProfile(resources.seed, dataDir);
330
+ if (seeded === "no-seed") {
331
+ throw new Error(
332
+ `dsh desktop: the packaged app carries no ${PROFILE_NAME} profile seed at ${resources.seed}`,
333
+ );
334
+ }
335
+ await shellLog(`profile 种子:${seeded === "seeded" ? "已重种" : "已是最新"}`);
336
+ }
337
+ // **两种形态都不要求上游的前端静态产物**:页面与资源由这条链(vite + 联邦预编译,见
338
+ // `@morlay/dsh-client-modules`)在宿主进程里现场提供,壳只是把请求经字节管道交给它。所以这里不再检查
339
+ // `@deepseek-ai/dsh-web-frontend/dist`——profile 的 `dsh.profile.bundles` 声明了那条链,就不该再要求它。
340
+
341
+ let host: DesktopHostProcess | undefined;
342
+ let mainWindow: BrowserWindowType | undefined;
343
+ let quitConfirmed = false;
344
+ let quitPrompting = false;
345
+ /** preload 落盘的位置:字节由宿主现场打包(sandboxed preload 只能是 CJS),这里只是放它。 */
346
+ const preloadPathOf = (): string => join(app.getPath("userData"), "preload-app.cjs");
347
+
348
+ // 只有打包形态拦一次确认(开发形态关窗即退出)。
349
+ const confirmQuit = async (window?: BrowserWindowType): Promise<void> => {
350
+ if (quitPrompting) return;
351
+ quitPrompting = true;
352
+ try {
353
+ const options = {
354
+ type: "question" as const,
355
+ buttons: ["Cancel", "Quit"],
356
+ defaultId: 0,
357
+ cancelId: 0,
358
+ noLink: true,
359
+ message: `Quit ${appConfig.displayName}?`,
360
+ detail: "The desktop backend and its running sessions will stop.",
361
+ };
362
+ const parent = window ?? mainWindow;
363
+ const { response } =
364
+ parent === undefined || parent.isDestroyed()
365
+ ? await dialog.showMessageBox(options)
366
+ : await dialog.showMessageBox(parent, options);
367
+ if (response !== 1) return;
368
+ quitConfirmed = true;
369
+ app.quit();
370
+ } finally {
371
+ quitPrompting = false;
372
+ }
373
+ };
374
+
375
+ let hostFailureHandled = false;
376
+ // 底层服务挂掉后请求只会拿到 503;这里直接拦截:报出原因并退出,不让页面停在半死状态。
377
+ const handleHostFailure = (error: Error): void => {
378
+ if (hostFailureHandled) return;
379
+ hostFailureHandled = true;
380
+ void shellLog(`后端停止:${error.message}`);
381
+ void shellLog(`宿主停止:${error.message}`);
382
+ dialog.showErrorBox("dsh desktop backend stopped", error.message);
383
+ quitConfirmed = true;
384
+ app.exit(1);
385
+ };
386
+
387
+ const startHost = async (
388
+ target: string = profileDir,
389
+ ): Promise<{ process: DesktopHostProcess; ready: DesktopHostReady }> => {
390
+ const loader = sourceLoader(resources);
391
+ const next = new DesktopHostProcess(resources.node, runtimeDir, target, hostInspectPort, {
392
+ nodeArgs: [`--import=${loader}`],
393
+ // 宿主输出落盘:打包形态 GUI 里看不到它的 stdout/stderr,而"少服务/某个 bundle 没起来"的原因就在那里。
394
+ logFile: join(dataDir ?? join(process.cwd(), ".dsh-store"), "logs", "host.log"),
395
+ // 宿主入口一律按 node 的解析取**源码**(`@morlay/dsh-desktop-host` 的 exports 指 `src/index.ts`,
396
+ // 由上面的 loader 转译):两种形态同一条路,部署里不必再落一份 dist 载荷。
397
+ //
398
+ // 解析基准是**壳自己**:`@morlay/dsh-desktop-host` 是壳的依赖(打包后落在 `Resources/app/node_modules`
399
+ // 里)。以前这里以**运行时根**为基准(`Resources/runtime/index.js`),能解析只是因为 runtime 里还留着一份
400
+ // 重复的闭包——runtime 现在只放工具链(node / pnpm / loader),依赖面在壳与 profile 各自那一份里。
401
+ // 宿主入口:两种形态都从壳自己的解析面取(打包形态落在 `Resources/app/node_modules` 里,进 asar)。
402
+ // 宿主跑的就是 Electron 的 node(见上面 `node` 与 `ELECTRON_RUN_AS_NODE`),asar 补丁因此对它生效,
403
+ // 入口与其依赖树都能留在 asar 内——只有 native(`*.node`)必须 unpack 出来。
404
+ entry: createRequire(import.meta.url).resolve(DESKTOP_HOST_PACKAGE),
405
+
406
+ extraEnv: {
407
+ // 宿主进程的"node"是 Electron 二进制:这个开关让它当 node 用(上游 `node-environment.ts` 同款)。
408
+ ELECTRON_RUN_AS_NODE: "1",
409
+ ...(dataDir === undefined ? {} : { DSH_HOME: dataDir }),
410
+ // 宿主按它现场打包 preload(见 `@morlay/dsh-desktop-host/preload-source`)。
411
+ ...(globalThis.__DSH_SHELL_ROOT__ === undefined
412
+ ? {}
413
+ : { DSH_DESKTOP_SHELL_ROOT: globalThis.__DSH_SHELL_ROOT__ }),
414
+ // 形态标志:壳起宿主时会过滤掉所有 `DSH_DESKTOP_*`(那些是 Electron 侧的元数据),所以这条链要
415
+ // 判断"跑在桌面形态里"得用别的名字(前端资源的预编译策略据此分流)。
416
+ DSH_DEV_FORM: "desktop",
417
+ // 这条链是 fs / 哈希密集的(预编译按包读源码、算指纹、写产物 + vite 的转译),这些全走 libuv 线程池,
418
+ // 默认只有 4:并发一多请求就排在池子外。bundle 形态不经过 `dshdev`,所以这里也设一份。
419
+ UV_THREADPOOL_SIZE: process.env["UV_THREADPOOL_SIZE"] ?? "64",
420
+ ...(development === undefined ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
421
+ ...sourceLoaderEnvironment(loader),
422
+ },
423
+ // `ps` 里能把后端与别的 node 进程区分开。
424
+ processTitle: `${appConfig.name}-server`,
425
+ // 打包形态的包操作使用随包 pnpm;dev 形态回退到 PATH 上的 pnpm。
426
+ ...(development === undefined
427
+ ? { packageManager: { pnpm: resources.pnpm, nodeBin: resources.nodeBin } }
428
+ : {}),
429
+ spawn: shellWrappedSpawn,
430
+ onFailure: handleHostFailure,
431
+ });
432
+ const ready = await next.start();
433
+ return { process: next, ready };
434
+ };
435
+
436
+ // 应用页面的全部请求(文档、资源、插件 bundle、API、流)都经字节管道交给宿主进程内的 webServer。
437
+ protocol.handle(SCHEME, async (request) => {
438
+ const url = new URL(request.url);
439
+ if (url.hostname !== "app") return new Response(null, { status: 404 });
440
+ const active = host;
441
+ if (active === undefined) return new Response("backend unavailable", { status: 503 });
442
+ if (!app.isPackaged) void shellLog(`recv ${request.method} ${url.pathname}`);
443
+ // **去掉条件请求头**:宿主的响应要包成 Fetch `Response`,而它不接受 304(`Invalid response status
444
+ // code 304`)——vite 对源码模块正是回 304 的(`/@vite/client`、`/index.ts` 那些),于是每次刷新都在
445
+ // 这里抛错。不带条件头,服务端就只会回 200(这条链的产物也不大,不值得为 304 冒这个险)。
446
+ const conditional = new Request(request.url, {
447
+ method: request.method,
448
+ headers: [...request.headers].filter(
449
+ ([name]) => name !== "if-none-match" && name !== "if-modified-since",
450
+ ),
451
+ ...(request.body === null ? {} : { body: request.body, duplex: "half" }),
452
+ });
453
+ const response = await active.fetch(conditional);
454
+ // dev 形态打印每个应用请求:排查「页面 → 壳 → 宿主」断在哪一段。
455
+ if (!app.isPackaged)
456
+ void shellLog(`${request.method} ${url.pathname} → ${String(response.status)}`);
457
+ return response;
458
+ });
459
+
460
+ installDesktopDirectoryPicker(() => mainWindow, SCHEME);
461
+
462
+ // 桌面流载体:主进程自己构造 Request(body 是流——首行定 endpoint/payload,后续行是上行项),
463
+ // 把响应体逐块推回页面——页面 fetch 到自定义协议的 POST body 在 Chromium 上不可靠。
464
+ let nextStreamId = 1;
465
+ const streamEncoder = new TextEncoder();
466
+ interface ActiveStream {
467
+ readonly abort: AbortController;
468
+ // 宿主请求体的写端;上行结束或取消后置空。
469
+ uplink: ReadableStreamDefaultController<Uint8Array> | undefined;
470
+ }
471
+ const activeStreams = new Map<number, ActiveStream>();
472
+ // 结束宿主请求体:已取消/已结束的流上 close 会抛,这里只当收尾。
473
+ const closeStreamUplink = (stream: ActiveStream): void => {
474
+ const uplink = stream.uplink;
475
+ if (uplink === undefined) return;
476
+ stream.uplink = undefined;
477
+ try {
478
+ uplink.close();
479
+ } catch (error) {
480
+ if (!app.isPackaged) void shellLog("stream uplink close failed %o", error);
481
+ }
482
+ };
483
+ ipcMain.handle(DESKTOP_IPC.streamOpen, async (event, endpoint: unknown, payload: unknown) => {
484
+ assertDesktopSender(event, SCHEME, ["app"]);
485
+ if (typeof endpoint !== "string") throw new Error("dsh desktop: stream endpoint must be text");
486
+ const active = host;
487
+ if (active === undefined) throw new Error("dsh desktop: Host is unavailable");
488
+ if (!app.isPackaged) void shellLog(`stream open ${endpoint}`);
489
+ const id = nextStreamId++;
490
+ const abort = new AbortController();
491
+ const stream: ActiveStream = { abort, uplink: undefined };
492
+ const body = new ReadableStream<Uint8Array>({
493
+ start: (controller) => {
494
+ stream.uplink = controller;
495
+ controller.enqueue(streamEncoder.encode(encodeDesktopStreamOpen(endpoint, payload)));
496
+ },
497
+ cancel: () => {
498
+ stream.uplink = undefined;
499
+ },
500
+ });
501
+ activeStreams.set(id, stream);
502
+ const request = new Request(`http://127.0.0.1${DESKTOP_STREAM_PATH}`, {
503
+ method: "POST",
504
+ headers: { "content-type": "application/json" },
505
+ body,
506
+ signal: abort.signal,
507
+ duplex: "half",
508
+ } as RequestInit);
509
+ void (async () => {
510
+ try {
511
+ const response = await active.fetch(request);
512
+ if (!app.isPackaged) void shellLog(`stream ${endpoint} → ${String(response.status)}`);
513
+ event.sender.send(DESKTOP_IPC.streamChunk, { id, status: response.status });
514
+ if (response.body === null) {
515
+ event.sender.send(DESKTOP_IPC.streamEnd, { id });
516
+ return;
517
+ }
518
+ const decoder = new TextDecoder();
519
+ for await (const chunk of response.body) {
520
+ event.sender.send(DESKTOP_IPC.streamChunk, { id, data: decoder.decode(chunk) });
521
+ }
522
+ event.sender.send(DESKTOP_IPC.streamEnd, { id });
523
+ } catch (error) {
524
+ event.sender.send(DESKTOP_IPC.streamError, {
525
+ id,
526
+ message: error instanceof Error ? error.message : String(error),
527
+ });
528
+ } finally {
529
+ // 流结束(或出错)时上行若还开着就收尾:宿主那边不必等一个不会再来的 end。
530
+ closeStreamUplink(stream);
531
+ activeStreams.delete(id);
532
+ }
533
+ })();
534
+ return id;
535
+ });
536
+ ipcMain.on(DESKTOP_IPC.streamCancel, (event, id: unknown) => {
537
+ if (mainWindow === undefined || event.sender !== mainWindow.webContents) return;
538
+ if (typeof id !== "number") return;
539
+ activeStreams.get(id)?.abort.abort();
540
+ });
541
+ // 上行项与上行结束都写成宿主请求体的行:请求体的 end 让宿主侧的 uplink 迭代结束。
542
+ ipcMain.on(DESKTOP_IPC.streamUplink, (event, id: unknown, item: unknown) => {
543
+ if (mainWindow === undefined || event.sender !== mainWindow.webContents) return;
544
+ if (typeof id !== "number") return;
545
+ const uplink = activeStreams.get(id)?.uplink;
546
+ if (uplink === undefined) return;
547
+ try {
548
+ uplink.enqueue(streamEncoder.encode(encodeDesktopStreamItem(item)));
549
+ } catch (error) {
550
+ if (!app.isPackaged) void shellLog("stream uplink rejected %o", error);
551
+ }
552
+ });
553
+ ipcMain.on(DESKTOP_IPC.streamUplinkEnd, (event, id: unknown) => {
554
+ if (mainWindow === undefined || event.sender !== mainWindow.webContents) return;
555
+ if (typeof id !== "number") return;
556
+ const stream = activeStreams.get(id);
557
+ if (stream === undefined) return;
558
+ closeStreamUplink(stream);
559
+ });
560
+
561
+ // 只有主窗口可以把自己的配色同步给原生材质。
562
+ ipcMain.on(DESKTOP_IPC.nativeThemeSet, (event, source: unknown) => {
563
+ if (mainWindow === undefined || event.sender !== mainWindow.webContents) return;
564
+ if (source === "light" || source === "dark" || source === "system")
565
+ nativeTheme.themeSource = source;
566
+ });
567
+
568
+ // 上游 @deepseek-ai/dsh-client-shortcuts 在桌面运行时要求 window.dshDesktop.keyboard:
569
+ // 这里装上原生侧(设备偏好 + 按键转发),preload 负责按那份契约暴露给页面。
570
+ const shortcuts = installDesktopShortcuts(
571
+ () => mainWindow,
572
+ app.getPath("userData"),
573
+ process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux",
574
+ SCHEME,
575
+ );
576
+ app.on("will-quit", () => {
577
+ shortcuts.dispose();
578
+ });
579
+
580
+ /** 宿主回环的 ws 端口:宿主起好之后填入(窗口重建时仍用它)。 */
581
+ // 窗口载入的地址:宿主 webServer 的 origin(`http://127.0.0.1:<port>`)。以前是自定义协议
582
+ // `dsh-custom-next://app/`——那条路没有 origin,settings 的 describe 流等长连接拿不到答复。
583
+ let hostOrigin = "";
584
+ let appPreload = preloadPathOf();
585
+ const createMainWindow = (): BrowserWindowType => {
586
+ const window = createWindow(appPreload, appConfig.window);
587
+ mainWindow = window;
588
+ shortcuts.attach(window);
589
+ window.once("ready-to-show", () => {
590
+ if (!window.isDestroyed()) window.show();
591
+ });
592
+ window.on("close", (event) => {
593
+ if (!app.isPackaged || quitConfirmed) return;
594
+ event.preventDefault();
595
+ void confirmQuit(window);
596
+ });
597
+ window.on("closed", () => {
598
+ if (mainWindow === window) mainWindow = undefined;
599
+ });
600
+ return window;
601
+ };
602
+ focusPrimaryWindow = () => {
603
+ const window = mainWindow;
604
+ if (window === undefined || window.isDestroyed()) {
605
+ const replacement = createMainWindow();
606
+ void replacement.loadURL(hostOrigin);
607
+ return;
608
+ }
609
+ if (window.isMinimized()) window.restore();
610
+ window.show();
611
+ window.focus();
612
+ };
613
+
614
+ // 后端调试面:View 菜单里的 Node inspector 开关,端点在需要时才开(决策见 .agents/adrs)。
615
+ const developerMenu = installDeveloperMenu({
616
+ scheme: SCHEME,
617
+ appName: appConfig.displayName,
618
+ host: () => host,
619
+ mainWindow: () => mainWindow,
620
+ });
621
+ app.on("will-quit", () => {
622
+ developerMenu.dispose();
623
+ });
624
+
625
+ const started = await startHost();
626
+ host = started.process;
627
+ hostOrigin = started.ready.origin;
628
+ // preload 的字节由宿主机现场打包(sandboxed preload 只能 CJS,也挂不了 hooks):取回来落到用户数据目录,
629
+ // 再把路径交给窗口——装配目录里因此没有预产的 `dist/`。
630
+ appPreload = preloadPathOf();
631
+ // 直连宿主的真 webServer 取它(`installPreloadSource` 把这条路由注册在它上面)。以前走自定义协议 →
632
+ // 字节管道,而那条路已经不通向 webServer 了(页面改从 origin 载入之后,管道不再承载请求)。
633
+ const preload = await fetch(new Request(`${hostOrigin}${PRELOAD_SOURCE_PATH}`));
634
+ if (!preload.ok) {
635
+ throw new Error(
636
+ `dsh desktop: preload bundling failed (${String(preload.status)}): ${await preload.text()}`,
637
+ );
638
+ }
639
+ await writeFile(appPreload, await preload.text(), "utf8");
640
+
641
+ mainWindow = createMainWindow();
642
+ await mainWindow.loadURL(hostOrigin);
643
+ if (development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== "0") {
644
+ mainWindow.webContents.openDevTools({ mode: "detach" });
645
+ }
646
+
647
+ app.on("activate", () => {
648
+ if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow();
649
+ });
650
+ app.on("window-all-closed", () => {
651
+ app.quit();
652
+ });
653
+ app.on("before-quit", (event) => {
654
+ if (app.isPackaged && !quitConfirmed) {
655
+ event.preventDefault();
656
+ void confirmQuit();
657
+ return;
658
+ }
659
+ if (host === undefined) return;
660
+ event.preventDefault();
661
+ const active = host;
662
+ host = undefined;
663
+ void active
664
+ .stop()
665
+ .catch((error: unknown) => {
666
+ void shellLog("入口失败或收尾失败 %o", error);
667
+ })
668
+ .finally(() => {
669
+ app.quit();
670
+ });
671
+ });
672
+ }
673
+
674
+ if (app.isPackaged) {
675
+ try {
676
+ // 打包应用必须在 whenReady 之前把 userData 指到 appconfig.json 的 id 目录。
677
+ const profile = join(app.getPath("appData"), appConfig.id);
678
+ await mkdir(profile, { recursive: true });
679
+ app.setPath("userData", profile);
680
+ } catch (error) {
681
+ void shellLog("入口失败或收尾失败 %o", error);
682
+ }
683
+ }
684
+
685
+ const ownsDesktopInstance = ((): boolean => {
686
+ if (!app.requestSingleInstanceLock()) {
687
+ app.quit();
688
+ return false;
689
+ }
690
+ app.on("second-instance", () => {
691
+ focusPrimaryWindow();
692
+ });
693
+ return true;
694
+ })();
695
+
696
+ if (ownsDesktopInstance)
697
+ void app
698
+ .whenReady()
699
+ .then(main)
700
+ .catch(async (error: unknown) => {
701
+ const message = error instanceof Error ? error.message : String(error);
702
+ void shellLog("入口失败或收尾失败 %o", error);
703
+ const diagnosticFile = process.env.DSH_DESKTOP_DIAGNOSTIC_FILE;
704
+ if (diagnosticFile !== undefined) {
705
+ await import("node:fs/promises")
706
+ .then((fs) =>
707
+ fs.writeFile(
708
+ diagnosticFile,
709
+ `${error instanceof Error ? (error.stack ?? message) : message}\n`,
710
+ ),
711
+ )
712
+ .catch(() => undefined);
713
+ }
714
+ dialog.showErrorBox("dsh desktop startup failed", message);
715
+ app.exit(1);
716
+ });