@morlay/dsh-desktop-shell 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.
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 +42 -21
  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
@@ -0,0 +1,131 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { access } from "node:fs/promises";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { rcFileFor, shellQuote, shellWrappedSpawn } from "../shell-env.ts";
5
+
6
+ const ORIGINAL_SHELL = process.env.SHELL;
7
+
8
+ async function firstInstalled(candidates: readonly string[]): Promise<string | undefined> {
9
+ for (const candidate of candidates) {
10
+ try {
11
+ await access(candidate);
12
+ return candidate;
13
+ } catch {
14
+ // 不在这个 runner 上,试下一个
15
+ }
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ // 包装行为与具体 shell 无关,用 runner 上第一个带 rc 文件的 shell:CI(ubuntu-latest)没有 zsh。
21
+ const RC_SHELL = await firstInstalled(["/bin/bash", "/usr/bin/bash", "/bin/zsh", "/usr/bin/zsh"]);
22
+
23
+ function useRcShell(): string {
24
+ if (RC_SHELL === undefined) throw new Error("runner has no bash/zsh to wrap with");
25
+ process.env.SHELL = RC_SHELL;
26
+ return RC_SHELL;
27
+ }
28
+
29
+ interface Outcome {
30
+ readonly code: number | null;
31
+ readonly stdout: string;
32
+ readonly stderr: string;
33
+ }
34
+
35
+ function runWrapped(
36
+ command: string,
37
+ args: readonly string[],
38
+ env: Record<string, string> = {},
39
+ ): Promise<Outcome> {
40
+ return new Promise<Outcome>((resolveOutcome, reject) => {
41
+ const child: ChildProcess = shellWrappedSpawn(command, args, {
42
+ stdio: ["ignore", "pipe", "pipe"],
43
+ env: { ...process.env, ...env },
44
+ });
45
+ let stdout = "";
46
+ let stderr = "";
47
+ child.stdout?.on("data", (chunk: Buffer) => {
48
+ stdout += chunk.toString("utf8");
49
+ });
50
+ child.stderr?.on("data", (chunk: Buffer) => {
51
+ stderr += chunk.toString("utf8");
52
+ });
53
+ child.once("error", reject);
54
+ child.once("exit", (code) => {
55
+ resolveOutcome({ code, stdout, stderr });
56
+ });
57
+ });
58
+ }
59
+
60
+ afterEach(() => {
61
+ if (ORIGINAL_SHELL === undefined) delete process.env.SHELL;
62
+ else process.env.SHELL = ORIGINAL_SHELL;
63
+ });
64
+
65
+ describe("rcFileFor", () => {
66
+ it("maps bash and zsh to their rc files, by basename", () => {
67
+ expect(rcFileFor("bash")).toBe("~/.bashrc");
68
+ expect(rcFileFor("/bin/bash")).toBe("~/.bashrc");
69
+ expect(rcFileFor("/usr/local/bin/zsh")).toBe("~/.zshrc");
70
+ });
71
+
72
+ it("sources no rc file for other shells", () => {
73
+ expect(rcFileFor("/usr/bin/fish")).toBe("");
74
+ expect(rcFileFor("sh")).toBe("");
75
+ expect(rcFileFor("")).toBe("");
76
+ });
77
+ });
78
+
79
+ describe("shellQuote", () => {
80
+ it("single-quotes a value and escapes embedded quotes", () => {
81
+ expect(shellQuote("plain")).toBe("'plain'");
82
+ expect(shellQuote("")).toBe("''");
83
+ expect(shellQuote("a b")).toBe("'a b'");
84
+ expect(shellQuote("it's")).toBe("'it'\\''s'");
85
+ });
86
+ });
87
+
88
+ describe("shellWrappedSpawn", () => {
89
+ it("runs the command directly when no rc file applies", async () => {
90
+ delete process.env.SHELL;
91
+ const outcome = await runWrapped(process.execPath, ["-p", "process.env.DSH_MARK"], {
92
+ DSH_MARK: "direct",
93
+ });
94
+
95
+ expect(outcome.code).toBe(0);
96
+ expect(outcome.stdout.trim()).toBe("direct");
97
+ });
98
+
99
+ it.skipIf(process.platform === "win32")(
100
+ "runs the command through the login shell with quoted arguments intact",
101
+ async () => {
102
+ useRcShell();
103
+ const outcome = await runWrapped("/bin/echo", ["a b'c", "d"]);
104
+
105
+ expect(outcome.code).toBe(0);
106
+ expect(outcome.stdout.trimEnd()).toBe("a b'c d");
107
+ },
108
+ );
109
+
110
+ it.skipIf(process.platform === "win32")(
111
+ "keeps the command in the same process, so its status is the shell's status",
112
+ async () => {
113
+ useRcShell();
114
+ const outcome = await runWrapped("/bin/sh", ["-c", "exit 7"]);
115
+
116
+ expect(outcome.code).toBe(7);
117
+ },
118
+ );
119
+
120
+ it.skipIf(process.platform === "win32")(
121
+ "passes the environment through the wrapper without leaking rc output",
122
+ async () => {
123
+ const shell = useRcShell();
124
+ const outcome = await runWrapped(process.execPath, ["-p", "process.env.SHELL"]);
125
+
126
+ expect(outcome.code).toBe(0);
127
+ expect(outcome.stdout.trim()).toBe(shell);
128
+ expect(outcome.stderr).toBe("");
129
+ },
130
+ );
131
+ });
@@ -0,0 +1,69 @@
1
+ // 壳产物的依赖边界:`app.asar` 里只有壳自己的字节(`@local/*`、`@morlay/dsh-desktop-host`
2
+ // 与配置里 `inline` 的上游包内联进来)。壳入口可达的模块图里**不允许**出现「既不在内联列表、
3
+ // 又解析不到」的裸引用——它们能过类型检查、能打出裸 `import`,但装进 `app.asar` 后只在真机
4
+ // 启动时炸。
5
+ //
6
+ // 这条边界是构建配置(`tsdown.config.ts` 的 `deps` / `inline`)与源码的共同事实,因此在这里守:
7
+ // 从壳入口出发沿相对 import 遍历,收集每个裸说明符,逐个核对白名单。
8
+ import { readFile } from "node:fs/promises";
9
+ import { dirname, relative, resolve } from "node:path";
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ const ROOT = resolve(import.meta.dirname, "..", "..");
13
+
14
+ // 装进 `app.asar` 的入口:壳主进程与 preload。
15
+ const SHELL_ENTRIES = ["src/index.ts", "src/preload-app.ts"];
16
+
17
+ // 壳产物里能解析到的裸说明符:node 内建、Electron 自带、内联进来的本地包,
18
+ // 以及 tsdown 配置里 `inline` 的那几个上游包(快捷键桥的设备偏好与原子写)。
19
+ const ALLOWED = [
20
+ /^node:/u,
21
+ /^electron$/u,
22
+ /^@local\//u,
23
+ /^@morlay\/dsh-desktop-host(?:\/|$)/u,
24
+ /^@deepseek-ai\/dsh-client-shortcuts(?:\/|$)/u,
25
+ /^@deepseek-ai\/dsh-atomic-write(?:\/|$)/u,
26
+ /^@deepseek-ai\/dsh-util-values(?:\/|$)/u,
27
+ /^@deepseek-ai\/dsh-util-crypto(?:\/|$)/u,
28
+ ];
29
+
30
+ // Type-only import 不留运行时字节,不在这条边界里。
31
+ function withoutTypeImports(text: string): string {
32
+ return text.replace(/(?:^|\n)\s*import\s+type[\s\S]*?from\s*["'][^"']+["']/gu, "\n");
33
+ }
34
+
35
+ function specifiersOf(text: string): string[] {
36
+ const source = withoutTypeImports(text);
37
+ const found: string[] = [];
38
+ const from = /(?:^|\n)\s*(?:import|export)[\s\S]*?from\s*["']([^"']+)["']/gu;
39
+ const sideEffect = /(?:^|\n)\s*import\s*["']([^"']+)["']/gu;
40
+ const dynamic = /\bimport\(\s*["']([^"']+)["']\s*\)/gu;
41
+ for (const pattern of [from, sideEffect, dynamic]) {
42
+ for (const match of source.matchAll(pattern)) found.push(match[1] ?? "");
43
+ }
44
+ return found.filter((specifier) => specifier !== "");
45
+ }
46
+
47
+ describe("shell bundle dependency boundary", () => {
48
+ it("keeps the shell's module graph free of packages app.asar cannot resolve", async () => {
49
+ const visited = new Set<string>();
50
+ const offenders: string[] = [];
51
+ const queue = SHELL_ENTRIES.map((entry) => resolve(ROOT, entry));
52
+
53
+ while (queue.length > 0) {
54
+ const file = queue.pop() as string;
55
+ if (visited.has(file)) continue;
56
+ visited.add(file);
57
+ for (const specifier of specifiersOf(await readFile(file, "utf8"))) {
58
+ if (specifier.startsWith(".")) {
59
+ queue.push(resolve(dirname(file), specifier));
60
+ continue;
61
+ }
62
+ if (ALLOWED.some((pattern) => pattern.test(specifier))) continue;
63
+ offenders.push(`${relative(ROOT, file)} → ${specifier}`);
64
+ }
65
+ }
66
+
67
+ expect(offenders).toEqual([]);
68
+ });
69
+ });
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createStreamUplink } from "../stream-uplink.ts";
3
+
4
+ function harness(): { uplink: ReturnType<typeof createStreamUplink>; log: string[] } {
5
+ const log: string[] = [];
6
+ const uplink = createStreamUplink(
7
+ (streamId, item) => {
8
+ log.push(`send ${String(streamId)} ${JSON.stringify(item)}`);
9
+ },
10
+ (streamId) => {
11
+ log.push(`end ${String(streamId)}`);
12
+ },
13
+ );
14
+ return { uplink, log };
15
+ }
16
+
17
+ describe("页面侧流的上行通道", () => {
18
+ it("id 未到时先排队,绑定后按序发出", () => {
19
+ const { uplink, log } = harness();
20
+ uplink.push({ typed: "a" });
21
+ uplink.push({ typed: "b" });
22
+ expect(log).toEqual([]);
23
+ uplink.bind(7);
24
+ expect(log).toEqual(['send 7 {"typed":"a"}', 'send 7 {"typed":"b"}']);
25
+ uplink.push({ typed: "c" });
26
+ expect(log.at(-1)).toBe('send 7 {"typed":"c"}');
27
+ expect(log.some((line) => line.startsWith("end"))).toBe(false);
28
+ });
29
+
30
+ it("上行结束时先交完排队项再收尾", () => {
31
+ const { uplink, log } = harness();
32
+ uplink.push({ typed: "a" });
33
+ uplink.close();
34
+ uplink.push({ typed: "late" });
35
+ uplink.bind(3);
36
+ expect(log).toEqual(['send 3 {"typed":"a"}', "end 3"]);
37
+ });
38
+
39
+ it("绑定后结束直接收尾,重复绑定报错", () => {
40
+ const { uplink, log } = harness();
41
+ uplink.bind(5);
42
+ uplink.close();
43
+ uplink.close();
44
+ expect(log).toEqual(["end 5"]);
45
+ expect(() => {
46
+ uplink.bind(6);
47
+ }).toThrow(/already bound/u);
48
+ });
49
+ });
@@ -0,0 +1,79 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ export const PROFILE_NAME = "desktop";
5
+
6
+ // profile 自己的 patch 文档名(与上游 `PROFILE_PATCH_FILENAME` 同值)。不 import 上游常量:
7
+ // `seed.ts` 进 `app.asar`,那里解析不到 `@deepseek-ai/*`。
8
+ export const PROFILE_PATCH_NAME = "cordis.patch.yml";
9
+
10
+ export interface AppWindowConfig {
11
+ readonly width: number;
12
+ readonly height: number;
13
+ readonly minWidth: number;
14
+ readonly minHeight: number;
15
+ }
16
+
17
+ export interface AppConfig {
18
+ readonly name: string;
19
+ readonly displayName: string;
20
+ readonly id: string;
21
+ readonly version: string;
22
+ readonly profile: typeof PROFILE_NAME;
23
+
24
+ readonly dataDir: string;
25
+ readonly window: AppWindowConfig;
26
+ }
27
+
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === "object" && value !== null;
30
+ }
31
+
32
+ function windowConfig(value: unknown): AppWindowConfig {
33
+ const record = isRecord(value) ? value : {};
34
+ return {
35
+ width: numberOr(record.width, 1280),
36
+ height: numberOr(record.height, 800),
37
+ minWidth: numberOr(record.minWidth, 800),
38
+ minHeight: numberOr(record.minHeight, 600),
39
+ };
40
+ }
41
+
42
+ function numberOr(value: unknown, fallback: number): number {
43
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
44
+ }
45
+
46
+ export async function loadAppConfig(exeDir: string): Promise<AppConfig> {
47
+ const path = join(exeDir, "appconfig.json");
48
+ const value: unknown = JSON.parse(await readFile(path, "utf8"));
49
+ if (
50
+ !isRecord(value) ||
51
+ typeof value.name !== "string" ||
52
+ value.name === "" ||
53
+ typeof value.displayName !== "string" ||
54
+ value.displayName === "" ||
55
+ typeof value.id !== "string" ||
56
+ value.id === "" ||
57
+ typeof value.version !== "string" ||
58
+ value.version === "" ||
59
+ value.profile !== PROFILE_NAME ||
60
+ (value.dataDir !== "xdg" && value.dataDir !== "env" && typeof value.dataDir !== "string")
61
+ ) {
62
+ throw new Error(`dsh desktop: invalid shell configuration ${path}`);
63
+ }
64
+ return {
65
+ name: value.name,
66
+ displayName: value.displayName,
67
+ id: value.id,
68
+ version: value.version,
69
+ profile: PROFILE_NAME,
70
+ dataDir: value.dataDir,
71
+ window: windowConfig(value.window),
72
+ };
73
+ }
74
+
75
+ export async function writeAppConfig(exeDir: string, config: AppConfig): Promise<void> {
76
+ await writeFile(join(exeDir, "appconfig.json"), `${JSON.stringify(config, undefined, 2)}\n`, {
77
+ mode: 0o600,
78
+ });
79
+ }
@@ -0,0 +1,30 @@
1
+ // 后端调试的前端地址:这份 DevTools 前端由桌面 host 自己托管(`@morlay/dsh-desktop-host` 的
2
+ // `/node-devtools` 前缀,产物取自上游 `@deepseek-ai/dsh-experimental-inspector` 的 `lib/devtools`)——
3
+ // 它按 `ws` 查询参数连端点(值形如 `host:port/path`,前端自己补 `ws://`),连的是 host 回报的
4
+ // Node inspector,而不是上游那条跨 realm 的同源路由(决策见
5
+ // [ADR 桌面档调试面用 Node inspector](../../.agents/adrs/20261008-桌面档调试面用Node-inspector而非跨realm面板.md))。
6
+
7
+ import { DEVTOOLS_ASSETS_PREFIX } from "@morlay/dsh-desktop-host/paths";
8
+
9
+ /** DevTools 前端在托管前缀下的入口路径(与上游底部面板用的是同一份产物)。 */
10
+ const DEVTOOLS_ENTRY = "devtools_app.html";
11
+
12
+ /**
13
+ * 把 host 回报的调试端点拼成应用 origin 下的 DevTools 入口。
14
+ * @param scheme - 应用自定义协议(壳派生自 app 名)。
15
+ * @param endpoint - host 回报的 WebSocket 端点(`ws://127.0.0.1:<port>/<uuid>`)。
16
+ * @returns 可在窗口里加载的入口 URL。
17
+ * @throws 端点不是 WebSocket 端点,或它的值不能原样进查询串。
18
+ */
19
+ export function nodeInspectorDevtoolsUrl(scheme: string, endpoint: string): string {
20
+ const target = new URL(endpoint);
21
+ if (target.protocol !== "ws:" && target.protocol !== "wss:")
22
+ throw new Error(`dsh desktop: the inspector endpoint is not a WebSocket url: ${endpoint}`);
23
+ if (target.search !== "")
24
+ throw new Error(`dsh desktop: the inspector endpoint must not carry a query: ${endpoint}`);
25
+ // 不转义:`:` 与 `/` 在查询值里合法,且这样无论前端做不做解码都拿到同一份地址。
26
+ const socket = `${target.host}${target.pathname}`;
27
+ if (!/^[\u0021-\u007e]+$/u.test(socket) || socket.includes("&") || socket.includes("#"))
28
+ throw new Error(`dsh desktop: the inspector endpoint is not usable in a query: ${endpoint}`);
29
+ return `${scheme}://app${DEVTOOLS_ASSETS_PREFIX}/${DEVTOOLS_ENTRY}?ws=${socket}&panel=console&disableLocaleInfoBar=true`;
30
+ }
@@ -0,0 +1,198 @@
1
+ // 壳的「后端调试」面:把 host 运行时开的 Node inspector 端点接到一个独立的 DevTools 窗口上。
2
+ // 为什么是 Node inspector 而不是上游那套跨 realm 面板,见
3
+ // [ADR 桌面档调试面用 Node inspector](../../.agents/adrs/20261008-桌面档调试面用Node-inspector而非跨realm面板.md)。
4
+
5
+ import { BrowserWindow, Menu, MenuItem, dialog } from "./electron.ts";
6
+ import type {
7
+ BrowserWindow as BrowserWindowType,
8
+ Menu as MenuType,
9
+ MenuItem as MenuItemType,
10
+ MenuItemConstructorOptions,
11
+ } from "electron";
12
+ import { nodeInspectorDevtoolsUrl } from "./developer-endpoint.ts";
13
+
14
+ // 后端调试面要的 host 面(`DesktopHostProcess` 的结构子集):命令 + 端点状态订阅。
15
+ export interface DeveloperHostProcess {
16
+ inspect(port?: number): void;
17
+ endInspect(): void;
18
+ onInspected(listener: (state: { url: string | null; message?: string }) => void): () => void;
19
+ }
20
+
21
+ export interface DeveloperMenuOptions {
22
+ // 应用自定义协议(壳派生自 app 名)。
23
+ readonly scheme: string;
24
+ readonly appName: string;
25
+ // 后端进程还没起来时返回 undefined——菜单仍然可用,点了给提示。
26
+ readonly host: () => DeveloperHostProcess | undefined;
27
+ // 对话框的父窗口。
28
+ readonly mainWindow: () => BrowserWindowType | undefined;
29
+ }
30
+
31
+ function reportFailure(options: DeveloperMenuOptions, title: string, detail: string): void {
32
+ // 开发菜单是壳里的调试面(dev 形态才出现):失败信息走终端,不进 shell.log。
33
+ console.error(`[dsh-shell] ${title}: ${detail}`);
34
+ const parent = options.mainWindow();
35
+ if (parent === undefined || parent.isDestroyed()) dialog.showErrorBox(title, detail);
36
+ else void dialog.showMessageBox(parent, { type: "error", message: title, detail });
37
+ }
38
+
39
+ // 往应用菜单的 View 子菜单里插两项(分隔符 + 调试项);现有菜单没有 View 时返回 false。
40
+ function insertIntoViewMenu(menu: MenuType | null, toggle: MenuItemConstructorOptions): boolean {
41
+ const view = menu?.items.find((entry) => entry.role === "viewMenu")?.submenu;
42
+ if (view === undefined) return false;
43
+ const devtools = view.items.findIndex((entry) => entry.role === "toggleDevTools");
44
+ const at = devtools === -1 ? view.items.length : devtools + 1;
45
+ view.insert(at, new MenuItem({ type: "separator" }));
46
+ view.insert(at + 1, new MenuItem(toggle));
47
+ return true;
48
+ }
49
+
50
+ // 现有菜单不可用时的兜底:只摆桌面应用该有的几项。macOS 的 app 菜单与 window 菜单不能省,
51
+ // 少了它们 Cmd+Q / Cmd+W 这类系统快捷键会失效。
52
+ function buildFallbackMenu(toggle: MenuItemConstructorOptions): MenuType {
53
+ const template: MenuItemConstructorOptions[] = [
54
+ ...(process.platform === "darwin" ? [{ role: "appMenu" } as const] : []),
55
+ { role: "fileMenu" },
56
+ { role: "editMenu" },
57
+ {
58
+ label: "View",
59
+ submenu: [
60
+ { role: "reload" },
61
+ { role: "forceReload" },
62
+ { role: "toggleDevTools" },
63
+ { type: "separator" },
64
+ toggle,
65
+ { type: "separator" },
66
+ { role: "resetZoom" },
67
+ { role: "zoomIn" },
68
+ { role: "zoomOut" },
69
+ { type: "separator" },
70
+ { role: "togglefullscreen" },
71
+ ],
72
+ },
73
+ { role: "windowMenu" },
74
+ ];
75
+ return Menu.buildFromTemplate(template);
76
+ }
77
+
78
+ /**
79
+ * 把「后端调试(Node Inspector)」挂进应用菜单的 View 里,并管理它那个 DevTools 窗口。
80
+ * 勾选 ⇔ 端点开着 ⇔ 窗口在:关窗口、取消勾选、host 回报端点没了,三条都收敛到同一状态。
81
+ * @param options - 协议、app 名与 host 取值函数。
82
+ * @returns 退订与收尾。
83
+ */
84
+ export function installDeveloperMenu(options: DeveloperMenuOptions): { dispose(): void } {
85
+ let window: BrowserWindowType | undefined;
86
+ // 我们自己关窗口时置位:那一刻别再当成「用户关掉调试」。
87
+ let closingWindow = false;
88
+ let releaseInspected: (() => void) | undefined;
89
+ // 菜单项实例只有挂上之后才有,勾选状态一律经它同步(模板对象里拿不到实例)。
90
+ let toggleItem: MenuItemType | undefined;
91
+ const setChecked = (checked: boolean): void => {
92
+ if (toggleItem !== undefined) toggleItem.checked = checked;
93
+ };
94
+
95
+ function closeWindow(): void {
96
+ const current = window;
97
+ window = undefined;
98
+ if (current === undefined || current.isDestroyed()) return;
99
+ // 标志由 `closed` 处理器消费:`close()` 之后 `closed` 是另派发的,不能在这里复位。
100
+ closingWindow = true;
101
+ current.close();
102
+ }
103
+
104
+ // 收掉这次调试:订阅、窗口、host 端点一起消失(重复调用是幂等的)。
105
+ function closeInspectorHost(): void {
106
+ releaseInspected?.();
107
+ releaseInspected = undefined;
108
+ closeWindow();
109
+ try {
110
+ options.host()?.endInspect();
111
+ } catch (error) {
112
+ console.error("[dsh-shell] host inspector shutdown failed", error);
113
+ }
114
+ }
115
+
116
+ function openWindow(endpoint: string): void {
117
+ const current = window;
118
+ if (current !== undefined && !current.isDestroyed()) {
119
+ current.focus();
120
+ return;
121
+ }
122
+ let target: string;
123
+ try {
124
+ target = nodeInspectorDevtoolsUrl(options.scheme, endpoint);
125
+ } catch (error) {
126
+ setChecked(false);
127
+ releaseInspected?.();
128
+ releaseInspected = undefined;
129
+ reportFailure(options, "后端调试", error instanceof Error ? error.message : String(error));
130
+ return;
131
+ }
132
+ const created = new BrowserWindow({
133
+ width: 1_280,
134
+ height: 820,
135
+ title: `${options.appName} · Node Inspector`,
136
+ backgroundColor: "#202124",
137
+ });
138
+ window = created;
139
+ created.on("closed", () => {
140
+ if (window === created) window = undefined;
141
+ // 标志是一次性的:无论谁关的,用过就复位。
142
+ const closingOurselves = closingWindow;
143
+ closingWindow = false;
144
+ if (closingOurselves) return;
145
+ // 用户关掉窗口就等于关掉这次调试:勾选、端点、窗口收敛到「没开」。
146
+ closeInspectorHost();
147
+ setChecked(false);
148
+ });
149
+ void created.loadURL(target);
150
+ }
151
+
152
+ const toggle: MenuItemConstructorOptions = {
153
+ id: "desktop-host-inspector",
154
+ label: "后端调试(Node Inspector)",
155
+ type: "checkbox",
156
+ checked: false,
157
+ click: (menu) => {
158
+ toggleItem = menu;
159
+ if (!menu.checked) {
160
+ closeInspectorHost();
161
+ return;
162
+ }
163
+ const host = options.host();
164
+ if (host === undefined) {
165
+ setChecked(false);
166
+ reportFailure(options, "后端调试", "后端进程还没就绪,稍后再试。");
167
+ return;
168
+ }
169
+ releaseInspected?.();
170
+ releaseInspected = host.onInspected((state) => {
171
+ if (state.url !== null) {
172
+ openWindow(state.url);
173
+ return;
174
+ }
175
+ setChecked(false);
176
+ closeWindow();
177
+ if (state.message !== undefined)
178
+ reportFailure(options, "后端调试端点没有开起来", state.message);
179
+ });
180
+ try {
181
+ // 端口交给 host 挑(0 = 随机),壳不猜;已有端点(dev 的 `--inspect`)由 host 复用。
182
+ host.inspect(0);
183
+ } catch (error) {
184
+ releaseInspected?.();
185
+ releaseInspected = undefined;
186
+ setChecked(false);
187
+ reportFailure(options, "后端调试", error instanceof Error ? error.message : String(error));
188
+ }
189
+ },
190
+ };
191
+
192
+ // 默认菜单不一定就绪(真机上 `Menu.getApplicationMenu()` 可能是 null):能插就插,
193
+ // 插不进去就自建一套——调试面不该因为菜单形态缺失而整个不可用。
194
+ if (!insertIntoViewMenu(Menu.getApplicationMenu(), toggle))
195
+ Menu.setApplicationMenu(buildFallbackMenu(toggle));
196
+
197
+ return { dispose: closeInspectorHost };
198
+ }
@@ -0,0 +1,37 @@
1
+ import { dialog, ipcMain } from "./electron.ts";
2
+ import type { BrowserWindow as BrowserWindowType } from "electron";
3
+ import { DESKTOP_IPC, assertDesktopSender } from "./ipc.ts";
4
+
5
+ export function installDesktopDirectoryPicker(
6
+ getWindow: () => BrowserWindowType | undefined,
7
+ scheme: string,
8
+ ): void {
9
+ const pending = new WeakMap<BrowserWindowType, Promise<string | null>>();
10
+ ipcMain.handle(DESKTOP_IPC.directoryPick, async (event) => {
11
+ const window = getWindow();
12
+ if (
13
+ window === undefined ||
14
+ window.isDestroyed() ||
15
+ event.sender !== window.webContents ||
16
+ event.senderFrame !== window.webContents.mainFrame
17
+ ) {
18
+ throw new Error("dsh desktop: rejected directory picker from an unowned renderer");
19
+ }
20
+ assertDesktopSender(event, scheme, ["app"]);
21
+ const existing = pending.get(window);
22
+ if (existing !== undefined) return existing;
23
+ if (window.isMinimized()) window.restore();
24
+ window.show();
25
+ window.focus();
26
+ const result = dialog
27
+ .showOpenDialog(window, { properties: ["openDirectory", "createDirectory"] })
28
+ .then(({ canceled, filePaths }) =>
29
+ window.isDestroyed() || canceled ? null : (filePaths[0] ?? null),
30
+ )
31
+ .finally(() => {
32
+ pending.delete(window);
33
+ });
34
+ pending.set(window, result);
35
+ return result;
36
+ });
37
+ }
@@ -0,0 +1,58 @@
1
+ // 桌面文档标记:客户端按这些属性切换窗口布局(macOS 交通灯内嵌、Windows 自绘 caption)。
2
+
3
+ // Windows 标题栏高度(DIP),与窗口 `titleBarOverlay.height` 一致。
4
+ export const WINDOWS_TITLEBAR_HEIGHT = 40;
5
+
6
+ // Web UI 写入的主题来源属性。
7
+ const THEME_SOURCE_ATTRIBUTE = "data-ds-theme-source";
8
+
9
+ function whenRootReady(root: Document, run: () => void): void {
10
+ // preload 可能早于文档根存在。
11
+ if ((root.documentElement as HTMLElement | null) === null)
12
+ root.defaultView?.addEventListener("DOMContentLoaded", run, { once: true });
13
+ else run();
14
+ }
15
+
16
+ // 标记页面平台:macOS 布局(侧边栏顶部拖拽条与 logo 下沉)据此启用。
17
+ export function markDocumentPlatform(platform: NodeJS.Platform, root: Document): void {
18
+ whenRootReady(root, () => {
19
+ root.documentElement.dataset.platform = platform;
20
+ });
21
+ }
22
+
23
+ // Windows 自绘 caption 标记:布局据此留出标题栏高度、把控制按钮位置让开。
24
+ export function markWindowsTitlebar(platform: NodeJS.Platform, root: Document): void {
25
+ if (platform !== "win32") return;
26
+ whenRootReady(root, () => {
27
+ root.documentElement.dataset.windowsTitlebar = "";
28
+ root.documentElement.style.setProperty(
29
+ "--dsh-windows-titlebar-height",
30
+ `${String(WINDOWS_TITLEBAR_HEIGHT)}px`,
31
+ );
32
+ });
33
+ }
34
+
35
+ // macOS:把主题来源转发给主进程,窗口 vibrancy 才会跟随应用配色。
36
+ export function syncNativeTheme(
37
+ platform: NodeJS.Platform,
38
+ root: Document,
39
+ send: (source: string) => void,
40
+ ): void {
41
+ if (platform !== "darwin") return;
42
+ let sent: string | undefined;
43
+ const publish = (): void => {
44
+ const value = root.documentElement.getAttribute(THEME_SOURCE_ATTRIBUTE);
45
+ if (value === null || value === sent) return;
46
+ sent = value;
47
+ send(value);
48
+ };
49
+ const observe = (): void => {
50
+ new MutationObserver(publish).observe(root.documentElement, {
51
+ attributeFilter: [THEME_SOURCE_ATTRIBUTE],
52
+ });
53
+ publish();
54
+ };
55
+ if (root.readyState === "loading")
56
+ root.defaultView?.addEventListener("DOMContentLoaded", observe, { once: true });
57
+ else observe();
58
+ }
package/src/dshhome.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ import type { AppConfig } from "./appconfig.ts";
4
+
5
+ export function xdgDataHome(): string {
6
+ if (process.platform === "darwin") return join(homedir(), "Library", "Application Support");
7
+ const configured = process.env.XDG_DATA_HOME;
8
+ if (configured !== undefined && configured.trim() !== "") return resolve(configured);
9
+ return join(homedir(), ".local", "share");
10
+ }
11
+
12
+ // 解析 `dataDir` 的三态:`xdg`(平台数据目录 + app 名)、`env`(交给环境,这里没有具体路径)、
13
+ // 绝对路径。打包壳与 dev CLI(`--data-dir`)共用这一份,措辞与校验只写在这里。
14
+ export function resolveConfiguredHome(name: string, spec: string): string | undefined {
15
+ if (spec === "env") return undefined;
16
+ if (spec === "xdg") return join(xdgDataHome(), name);
17
+ if (!isAbsolute(spec)) {
18
+ throw new Error(
19
+ `dsh desktop: dataDir must be xdg, env, or an absolute path, got ${JSON.stringify(spec)}`,
20
+ );
21
+ }
22
+ return spec;
23
+ }
24
+
25
+ export function resolveDshHome(config: AppConfig): string | undefined {
26
+ const override = process.env.DSH_APP_DSH_HOME;
27
+ if (override !== undefined && override.trim() !== "") return resolve(override);
28
+ return resolveConfiguredHome(config.name, config.dataDir);
29
+ }