From 98de8c0925913d247d6e9005fa0acf586417a118 Mon Sep 17 00:00:00 2001 From: kingwl Date: Wed, 30 Sep 2026 13:37:58 +0800 Subject: [PATCH 1/3] refactor(desktop): share bundled primary-runtime pnpm --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/prepare-dsh.ts | 2 +- apps/desktop/scripts/prepare-runtime.ts | 23 ++----- apps/desktop/scripts/smoke-runtime.ts | 49 +++++++++++-- apps/desktop/src/main.ts | 2 +- apps/desktop/tests/fixtures/pnpm-smoke.mjs | 69 +++++++++++++++++++ .../tests/fixtures/runtime-payload-smoke.mjs | 30 +------- apps/desktop/tests/macos-signature.spec.ts | 2 +- apps/desktop/tests/main-startup.spec.ts | 15 ++++ .../desktop/tests/runtime-preparation.spec.ts | 58 ++++++++++++++++ 12 files changed, 200 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/tests/fixtures/pnpm-smoke.mjs create mode 100644 apps/desktop/tests/runtime-preparation.spec.ts diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index f412464ae6..403837df9f 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md /deepseek-harness-desktop: - en: eccac345d51428fa - zh: 4a448b3d63cb8117 + en: 65db3a3bc4473b62 + zh: 1e52f5889f72575b /deepseek-harness-desktop/terminal-command: en: bef92953c237363f zh: d21d6effd21b8c00 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index fa00c27e08..1b71ef8861 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -10,7 +10,7 @@ The first application-menu command, **About DeepSeek Harness**, opens Electron's Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. -Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations. +Desktop ships one pnpm distribution at `resources/runtime/primary-runtime/dependencies/pnpm`. Build-time production installation, Creator and the Web Plugin Manager execute its `bin/pnpm.mjs` with Electron Node mode; package operations do not require pnpm on PATH. The private Node launcher environment applies only to package operations. Agent workspace dependencies copy the same self-contained primary-runtime payload into the Harness home and execute pnpm with its standalone Node. Embedded Platform documents use a persistent WebContentsView partition keyed by a hash of the Platform origin and stable account ID. localStorage preferences, including dismissed notices, survive view closure and application restart; different accounts and origins use separate storage. The Host supplies the account ID from its latest successful profile read; while no ID is known, the document opens in a disposable partition that keeps no preference across opens. Opening a persistent partition clears cookies, filesystem, IndexedDB, Cache Storage, the HTTP and shader caches, service workers, and HTTP authentication before the document loads; a disposable partition clears all of its storage. Closing a view destroys the document, removes its request interceptors, and schedules the same cleanup, so authentication left by an unclean exit is cleared before the next document loads. The next open and application shutdown await that cleanup, and an update install waits for it before the installer takes over the exit. A failed cleanup rejects that open and blocks the same account until a later cleanup succeeds; other accounts keep opening. Sign-out destroys the document but retains account-scoped preferences for the next sign-in. A document already open in a disposable partition stays mounted when the account ID arrives for the same credential; the next open uses the account-scoped partition. The [storage decision](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.md) describes the retention policy. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode, synchronous getAuthToken() and getLocale() getters, and onLocaleChange(listener), which returns an unsubscribe function. Both getters read preload memory without further IPC. The bootstrap includes the resolved Desktop language (`zh_CN` or `en_US`); Settings language changes update the preload cache and notify the open Platform document without reloading. Platform applies this language before its first render and does not persist it as a browser preference. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 61caaf038d..8877edf9e5 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -10,7 +10,7 @@ Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 -Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。 +Desktop 在 `resources/runtime/primary-runtime/dependencies/pnpm` 中携带一份 pnpm 分发包。构建时的生产依赖安装、Creator 和 Web 插件管理器通过 Electron Node 模式执行其中的 `bin/pnpm.mjs`;包操作不要求 PATH 上存在 pnpm。私有 Node 启动器环境仅用于包操作。agent(智能体)的工作区依赖将同一份完整 primary-runtime 复制到 Harness 主目录,并使用其中的独立 Node 执行 pnpm。 Platform 内嵌文档使用持久化 WebContentsView 分区,分区名由 Platform 来源和稳定账号 ID 的哈希决定。localStorage 中的页面偏好(包括已关闭的通知)在关闭视图和重启应用后保留;不同账号和来源使用独立存储。账号 ID 来自 Host 最近一次成功的资料读取;尚无该 ID 时,文档在一次性分区中打开,该分区不跨次保留偏好。打开持久分区会先清理 Cookie、文件系统、IndexedDB、Cache Storage、HTTP 与着色器缓存、Service Worker 及 HTTP 认证状态;一次性分区则清理其全部存储。关闭视图会销毁文档、移除请求拦截器并安排相同的清理,因此异常退出遗留的认证会在下一个文档加载前被清除。下次打开和应用退出都会等待该清理完成,更新安装也会在安装器接管退出前等待。清理失败会使该次打开失败,并在后续清理成功前阻止同一账号打开;其他账号不受影响。退出登录会销毁文档,但保留账号偏好供下次登录使用。同一凭证下账号 ID 迟到时,已以一次性分区打开的文档保持挂载;下次打开使用账号分区。[存储决策](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.zh.md)说明保留策略。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode、同步的 getAuthToken() 和 getLocale() getter,以及返回取消订阅函数的 onLocaleChange(listener)。两个 getter 都只读取 preload 内存,不再调用 IPC。bootstrap 包含 Desktop 已解析的语言(`zh_CN` 或 `en_US`);Settings 语言变更会更新 preload 缓存并通知已打开的 Platform 文档,无需重载。Platform 在首屏渲染前应用该语言,且不将其持久化为浏览器偏好。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。 diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 442e01757e..3fdfc03200 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -42,7 +42,7 @@ const RUNTIME_ROOT = BUILD_PATHS.runtime const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet const NODE = join(BUILD_PATHS.electron, process.platform === 'win32' ? 'electron.exe' : 'Electron.app/Contents/MacOS/Electron') -const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs') +const PNPM = join(RUNTIME_ROOT, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs') function manifestVersion(path: string, subject: string): string { const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown } diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts index 1b88f091bc..0f81a05a22 100644 --- a/apps/desktop/scripts/prepare-runtime.ts +++ b/apps/desktop/scripts/prepare-runtime.ts @@ -4,7 +4,7 @@ import { packagingStep } from './packaging-step.mjs' import { execFileSync } from 'node:child_process' import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { parseArgs } from 'node:util' import { downloadArtifact } from '@electron/get' import extractZip from 'extract-zip' @@ -16,18 +16,6 @@ import { prepareCommandLink } from './prepare-command-link.ts' const BUILD_PATHS = resolveDesktopTargetBuildPaths() const RUNTIME_ROOT = BUILD_PATHS.runtime -function preparePnpm(): string { - const require = createRequire(import.meta.url) - const manifestPath = require.resolve('pnpm') - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { version?: unknown } - if (typeof manifest.version !== 'string') throw new Error('desktop runtime: pnpm manifest has no version') - const packageDir = dirname(manifestPath) - const destination = join(RUNTIME_ROOT, 'pnpm') - rmSync(destination, { recursive: true, force: true }) - cpSync(packageDir, destination, { recursive: true }) - return manifest.version -} - async function main(): Promise { const { values } = parseArgs({ options: { 'defer-primary-runtime-smoke': { type: 'boolean', default: false } } }) const target = resolveDesktopBuildTarget() @@ -47,21 +35,22 @@ async function main(): Promise { ['-c', 'Print LSMinimumSystemVersion', join(BUILD_PATHS.electron, 'Electron.app', 'Contents', 'Info.plist')], { encoding: 'utf8' }).trim() : undefined rmSync(RUNTIME_ROOT, { recursive: true, force: true }) mkdirSync(RUNTIME_ROOT, { recursive: true }) - const pnpmVersion = preparePnpm() + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime', + () => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] })) + const manifest = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'primary-runtime', 'runtime.json'), 'utf8')) as { pnpm?: unknown } + if (typeof manifest.pnpm !== 'string') throw new Error('desktop runtime: primary-runtime manifest has no pnpm version') cpSync(join(import.meta.dirname, 'node-bin'), join(RUNTIME_ROOT, 'bin'), { recursive: true }) chmodSync(join(RUNTIME_ROOT, 'bin', 'node'), 0o755) writeFileSync(join(RUNTIME_ROOT, 'versions.json'), `${JSON.stringify({ schemaVersion: 1, node: nodeVersion, - pnpm: pnpmVersion, + pnpm: manifest.pnpm, }, undefined, 2)}\n`) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli', async () => prepareDesktopCli(join(RUNTIME_ROOT, 'cli'), platform)) if (macosMinimumVersion !== undefined) prepareCommandLink(join(RUNTIME_ROOT, 'cli'), arch, macosMinimumVersion) cpSync(join(import.meta.dirname, '..', 'lib', 'command-manager-entry.js'), join(RUNTIME_ROOT, 'cli', 'command-manager.js')) cpSync(join(import.meta.dirname, 'command-path.ps1'), join(RUNTIME_ROOT, 'cli', 'command-path.ps1')) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime', - () => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] })) } await main() diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index f15f10a928..f968a9c21b 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -1,6 +1,6 @@ /** Boot the materialized target runtime without access to a user's Harness profile. */ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { execFile } from 'node:child_process' @@ -25,15 +25,34 @@ export async function smokeDesktopRuntime( ): Promise { const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-')) const profile = join(home, 'profiles', 'desktop') - const host = new DesktopHostProcess(node, root, profile, undefined, { ...environment, DSH_HOME: home }, + const hostEnvironment = { + ...Object.fromEntries(Object.entries(environment).filter(([name]) => !/^(?:npm|pnpm|corepack)_/iu.test(name))), + DSH_HOME: home, HOME: home, USERPROFILE: home, + XDG_CONFIG_HOME: home, XDG_CACHE_HOME: home, XDG_STATE_HOME: home, + } + const host = new DesktopHostProcess(node, root, profile, undefined, hostEnvironment, undefined, join(resourcesRuntime, 'primary-runtime'), - { pnpm: join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.cjs'), nodeBin: join(resourcesRuntime, 'bin') }) + { pnpm: join(resourcesRuntime, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'), nodeBin: join(resourcesRuntime, 'bin') }) let timer: ReturnType | undefined try { createPluginProfile(profile) const pluginName = 'desktop-runtime-smoke-plugin' - const plugin = join(profile, 'node_modules', pluginName) + const plugin = join(home, pluginName) mkdirSync(plugin, { recursive: true }) + const installedName = 'desktop-installed-smoke-plugin' + const installedPlugin = join(home, installedName) + mkdirSync(installedPlugin) + writeFileSync(join(installedPlugin, 'package.json'), JSON.stringify({ + name: installedName, version: '1.0.0', type: 'module', exports: './index.js', dsh: { bundle: { patch: './bundle.yml' } }, + })) + writeFileSync(join(installedPlugin, 'index.js'), ` +export function apply(ctx) { + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-installed', + handler(_request, response) { response.end('installed plugin ready') } })) +} +`) + writeFileSync(join(installedPlugin, 'bundle.yml'), `- insert:\n - id: ${installedName}\n name: ${installedName}\n inject: [webServer]\n`) + writeFileSync(join(profile, 'pnpm-workspace.yaml'), 'nodeLinker: hoisted\nautoInstallPeers: false\noffline: true\nupdateNotifier: false\n') const primary = join(resourcesRuntime, 'primary-runtime') const dependencies = workspaceDependencyPaths(primary, await readPrimaryRuntime(primary)) await promisify(execFile)(dependencies.python, ['-I', '-B', @@ -56,6 +75,15 @@ export function apply(ctx) { if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance') ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke', handler(_request, response) { response.end('plugin route ready') } })) + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-install', + async handler(_request, response) { + try { + response.end(JSON.stringify(await ctx.pluginManager.installBundle(${JSON.stringify(installedPlugin)}))) + } catch (error) { + response.statusCode = 500 + response.end(inspect(error, { depth: 5 })) + } + } })) ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office-cli', async handler(_request, response) { try { @@ -90,12 +118,13 @@ export function apply(ctx) { } } `) - writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills]\n') + writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills, pluginManager]\n') + cpSync(plugin, join(profile, 'node_modules', pluginName), { recursive: true }) const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { dependencies: Record dsh: { profile: { bundles: string[] } } } - manifest.dependencies[pluginName] = '1.0.0' + manifest.dependencies[pluginName] = `file:${plugin}` manifest.dsh.profile.bundles.push(pluginName) writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest)) writeFileSync(join(profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n') @@ -111,6 +140,14 @@ export function apply(ctx) { } const pluginResponse = await fetch(new URL('/desktop-smoke', ready.url), { headers: { cookie } }) if (await pluginResponse.text() !== 'plugin route ready') throw new Error('desktop runtime: plugin HTTP route failed') + const installation = await fetch(new URL('/desktop-smoke-install', ready.url), { + headers: { cookie }, signal: AbortSignal.timeout(120_000), + }) + const installationResult = await installation.text() + const installed = await fetch(new URL('/desktop-smoke-installed', ready.url), { headers: { cookie } }) + if (!installation.ok || !installed.ok || await installed.text() !== 'installed plugin ready') { + throw new Error(`desktop runtime: bundled pnpm plugin installation failed: ${installationResult}`) + } for (const { extension } of inputs) { const converted = await fetch(new URL(`/desktop-smoke-office/${extension}`, ready.url), { headers: { cookie }, signal: AbortSignal.timeout(120_000), diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b4b8153b7f..bf42a41005 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -154,7 +154,7 @@ function runtimeResources(): RuntimeResources { const nodeBin = development ? join(app.getAppPath(), 'scripts', 'node-bin') : join(process.resourcesPath, 'runtime', 'bin') const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined) ?? (development ? join(app.getAppPath(), 'node_modules', 'pnpm', 'bin', 'pnpm.mjs') - : join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs')) + : join(process.resourcesPath, 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')) const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined) ?? (development ? join(app.getAppPath(), '.desktop-build', 'development', 'project') : join(app.getAppPath(), 'dsh')) return { node, nodeBin, pnpm, dsh } diff --git a/apps/desktop/tests/fixtures/pnpm-smoke.mjs b/apps/desktop/tests/fixtures/pnpm-smoke.mjs new file mode 100644 index 0000000000..5d16769ab4 --- /dev/null +++ b/apps/desktop/tests/fixtures/pnpm-smoke.mjs @@ -0,0 +1,69 @@ +/** Install and use a local dependency with the packaged pnpm and Electron Node launcher. */ + +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' + +/** + * Exercise offline installation and package scripts without a system pnpm or Node installation. + * @param {string} resourcesRuntime - Packaged runtime directory outside ASAR. + * @returns {Promise} Resolves after execution and removal of the private installation. + */ +export async function checkPnpm(resourcesRuntime) { + assert.ok(process.versions.electron, 'Run the package-manager smoke with Electron Node mode') + assert.equal(existsSync(join(resourcesRuntime, 'pnpm')), false, 'Desktop must ship only primary-runtime pnpm') + const primary = join(resourcesRuntime, 'primary-runtime') + const pnpm = join(primary, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs') + const versions = JSON.parse(readFileSync(join(resourcesRuntime, 'versions.json'), 'utf8')) + const manifest = JSON.parse(readFileSync(join(primary, 'runtime.json'), 'utf8')) + assert.equal(versions.pnpm, manifest.pnpm) + const scratch = mkdtempSync(join(tmpdir(), 'desktop-pnpm-smoke-')) + try { + mkdirSync(join(scratch, 'dependency')) + writeFileSync(join(scratch, 'dependency', 'package.json'), JSON.stringify({ + name: 'desktop-pnpm-smoke-dependency', version: '1.0.0', main: 'index.cjs', + })) + writeFileSync(join(scratch, 'dependency', 'index.cjs'), 'exports.answer = 42\n') + writeFileSync(join(scratch, 'package.json'), JSON.stringify({ + name: 'desktop-node-script-smoke', private: true, + dependencies: { 'desktop-pnpm-smoke-dependency': 'file:./dependency' }, scripts: { check: 'node check.cjs' }, + })) + writeFileSync(join(scratch, 'npmrc'), '') + writeFileSync(join(scratch, 'check.cjs'), ` +const assert = require('node:assert/strict') +assert.equal(require('desktop-pnpm-smoke-dependency').answer, 42) +assert.equal(process.execPath, ${JSON.stringify(process.execPath)}) +assert.ok(process.versions.electron) +assert.ok(process.execArgv.includes('--expose-internals')) +assert.equal(typeof require('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function') +console.log('desktop-node-script-ok') +`) + const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => /^(?:systemroot|windir|comspec|LD_LIBRARY_PATH)$/iu.test(name))) + const systemBin = process.platform === 'win32' ? join(process.env.SystemRoot, 'System32') : '/usr/bin:/bin' + const run = args => execFileSync(process.execPath, ['--expose-internals', pnpm, ...args], { + cwd: scratch, encoding: 'utf8', timeout: 45_000, windowsHide: true, + env: { ...environment, pnpm_config_verify_deps_before_run: 'false', + ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, + PATH: `${join(resourcesRuntime, 'bin')}${delimiter}${systemBin}`, + HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch, + XDG_CONFIG_HOME: scratch, XDG_CACHE_HOME: scratch, XDG_STATE_HOME: scratch, + npm_config_userconfig: join(scratch, 'npmrc'), npm_config_globalconfig: join(scratch, 'npmrc'), + npm_config_store_dir: join(scratch, 'store'), npm_config_enable_global_virtual_store: 'false', + npm_config_update_notifier: 'false' }, + }) + assert.equal(run(['--version']).trim(), versions.pnpm) + run(['install', '--offline', '--ignore-scripts']) + assert.match(run(['run', 'check']), /desktop-node-script-ok/u) + } finally { + await rm(scratch, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) + } +} + +if (import.meta.main) { + assert.ok(process.argv[2], 'Pass the packaged runtime directory') + await checkPnpm(process.argv[2]) + console.log('desktop pnpm: offline installation and Electron Node package script passed') +} diff --git a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs index d0d7bb94f6..442303243c 100644 --- a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs +++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs @@ -8,6 +8,7 @@ import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { delimiter, dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { checkPnpm } from './pnpm-smoke.mjs' const runtime = process.argv[2] assert.ok(runtime, 'Pass the filtered resources/dsh directory') @@ -20,33 +21,6 @@ const resourcesRuntime = process.argv[3] ?? join(dirname(root), 'runtime') const requireRuntime = createRequire(join(root, 'package.json')) const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-')) -/** Run a package script with only the shipped node launcher available on PATH. */ -function checkPnpm() { - const bin = join(resourcesRuntime, 'bin') - const pnpm = join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.mjs') - writeFileSync(join(scratch, 'package.json'), JSON.stringify({ - name: 'desktop-node-script-smoke', private: true, scripts: { check: 'node check.cjs' }, - })) - writeFileSync(join(scratch, 'check.cjs'), ` -const assert = require('node:assert/strict') -assert.equal(process.execPath, ${JSON.stringify(process.execPath)}) -assert.ok(process.versions.electron) -assert.ok(process.execArgv.includes('--expose-internals')) -assert.equal(typeof require('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function') -console.log('desktop-node-script-ok') -`) - const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => /^(?:systemroot|windir|comspec)$/iu.test(name))) - const systemBin = process.platform === 'win32' ? join(process.env.SystemRoot, 'System32') : '/usr/bin:/bin' - // This dependency-free fixture checks script launch, without pnpm's implicit install and update-network check. - const output = execFileSync(process.execPath, ['--expose-internals', pnpm, 'run', 'check'], { - cwd: scratch, encoding: 'utf8', timeout: 45_000, - env: { ...environment, pnpm_config_verify_deps_before_run: 'false', - ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, - PATH: `${bin}${delimiter}${systemBin}`, HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch }, - }) - assert.match(output, /desktop-node-script-ok/u) -} - /** Spawn only a fixed Node program and await the terminal's drained exit event. */ async function checkPty() { const pty = requireRuntime('node-pty') @@ -157,7 +131,7 @@ function checkHtml() { try { const builtin = requireRuntime('node-addon-require-builtin') assert.equal(typeof builtin.requireBuiltin('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function') - checkPnpm() + await checkPnpm(resourcesRuntime) checkKoffi() await checkSharp() checkHtml() diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index c8d9f859af..da3f43e0eb 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -93,9 +93,9 @@ describe('desktop macOS release signature', () => { const ignored = (path: string): boolean => config.mac.signIgnore.some(pattern => new RegExp(pattern).test(path)) expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/en.lproj/locale.pak')).toBe(true) expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/resources.pak')).toBe(true) + expect(ignored('/App.app/Contents/Resources/runtime/primary-runtime/dependencies/pnpm/addon.node')).toBe(true) for (const path of [ '/App.app/Contents/Resources/runtime/node/node', - '/App.app/Contents/Resources/runtime/pnpm/addon.node', '/App.app/Contents/Frameworks/Electron.framework/Versions/A/library.dylib', '/App.app/Contents/Frameworks/Electron.framework', '/App.app', diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index b6b6fd476f..5ff9cad693 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -437,6 +437,21 @@ afterEach(async () => { }) describe('desktop main startup', () => { + it.each([true, false])('selects the package manager before Host startup (packaged=%s)', async (packaged) => { + harness.app.isPackaged = packaged + await readyForUpdate() + const host = harness.hosts[0]! + expect(host.node).toBe(process.execPath) + expect(host.primaryRuntime).toBe(packaged + ? join('desktop-test-resources', 'runtime', 'primary-runtime') : 'test-primary-runtime') + expect(host.packageManager).toMatchObject({ + pnpm: packaged + ? join('desktop-test-resources', 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs') + : 'test-pnpm', + nodeBin: packaged ? join('desktop-test-resources', 'runtime', 'bin') : join('desktop-test-app', 'scripts', 'node-bin'), + }) + }) + it('routes shell update documents and assets through the registered main protocol handler', async () => { const root = join(import.meta.dirname, '..') vi.spyOn(harness.app, 'getAppPath').mockReturnValue(root) diff --git a/apps/desktop/tests/runtime-preparation.spec.ts b/apps/desktop/tests/runtime-preparation.spec.ts new file mode 100644 index 0000000000..6a4b25a493 --- /dev/null +++ b/apps/desktop/tests/runtime-preparation.spec.ts @@ -0,0 +1,58 @@ +/** Desktop preparation publishes one package manager alongside its private Node launchers. */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ root: '' })) +vi.mock('../scripts/desktop-build-paths.mjs', () => ({ + resolveDesktopBuildTarget: () => 'mac-arm64', + resolveDesktopTargetBuildPaths: () => ({ + runtime: join(state.root, 'runtime'), electron: join(state.root, 'electron'), downloads: join(state.root, 'downloads'), + }), +})) +vi.mock('@electron/get', () => ({ downloadArtifact: async () => 'electron.zip' })) +vi.mock('extract-zip', () => ({ default: async () => {} })) +vi.mock('node:child_process', () => ({ execFileSync: () => '24.18.1\n' })) +vi.mock('../scripts/prepare-command-link.ts', () => ({ prepareCommandLink: () => {} })) +// The source-plane preparation spec supplies the one compiled CLI input; other copies use real files. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, cpSync: (...args: Parameters) => { + if (String(args[0]).endsWith(join('lib', 'command-manager-entry.js'))) actual.writeFileSync(args[1], '') + else actual.cpSync(...args) + } } +}) +vi.mock('../scripts/prepare-primary-runtime.ts', () => ({ + preparePrimaryRuntime: async () => { + const root = join(state.root, 'runtime', 'primary-runtime') + const pnpm = join(root, 'dependencies', 'pnpm', 'bin') + mkdirSync(pnpm, { recursive: true }) + writeFileSync(join(pnpm, 'pnpm.mjs'), '') + writeFileSync(join(root, 'runtime.json'), JSON.stringify({ pnpm: '11.7.0' })) + }, +})) + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + rmSync(state.root, { recursive: true, force: true }) +}) + +it('replaces stale resources with one pnpm distribution and records its version', async () => { + state.root = mkdtempSync(join(tmpdir(), 'desktop-runtime-preparation-')) + const runtime = join(state.root, 'runtime') + mkdirSync(join(runtime, 'pnpm'), { recursive: true }) + vi.stubGlobal('process', { ...process, argv: [process.execPath, 'prepare-runtime.ts'] }) + vi.stubEnv('DSH_DESKTOP_PACKAGING_RUN_DIR', undefined) + await import('../scripts/prepare-runtime.ts') + expect(existsSync(join(runtime, 'pnpm'))).toBe(false) + expect(existsSync(join(runtime, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'))).toBe(true) + expect(existsSync(join(runtime, 'bin', 'node'))).toBe(true) + expect(existsSync(join(runtime, 'cli', 'bin', 'dsh'))).toBe(true) + expect(existsSync(join(runtime, 'cli', 'command-manager.js'))).toBe(true) + expect(JSON.parse(readFileSync(join(runtime, 'versions.json'), 'utf8'))).toEqual({ + schemaVersion: 1, node: '24.18.1', pnpm: '11.7.0', + }) +}) From 6b261009956584e382c8462b6f7ab5d12982bc6d Mon Sep 17 00:00:00 2001 From: kingwl Date: Wed, 30 Sep 2026 14:09:56 +0800 Subject: [PATCH 2/3] refactor(desktop): clarify runtime preparation stages --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/prepare-runtime.ts | 61 +++++---- .../desktop/tests/runtime-preparation.spec.ts | 116 ++++++++++++++---- 5 files changed, 130 insertions(+), 55 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 403837df9f..9ec049b6a7 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -57,8 +57,8 @@ en: f176a8b1c7de3064 zh: 6ea2dbe6bb808c34 /deepseek-harness-desktop/package/windows-ev-signing: - en: 8efe3498dbe8686d - zh: a88c16f20a9430f3 + en: 5b85747ac1af43bc + zh: d5598c33c22c0806 /deepseek-harness-desktop/updates: en: d0df867b69cbffa4 zh: eeb47b79b86fe5ee diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 1b71ef8861..0445d6208a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -390,7 +390,7 @@ pnpm run prepare:desktop This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content. -Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. +Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. Runtime preparation clears stale resources and writes `runtime/versions.json` after Electron, the primary runtime and CLI resources are ready. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 8877edf9e5..965cf4c2b3 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -392,7 +392,7 @@ pnpm run prepare:desktop 这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。 -每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 +每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。运行时准备会清除旧资源,并在 Electron、primary-runtime 和 CLI 资源全部就绪后写入 `runtime/versions.json`。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts index 0f81a05a22..ba92fef0e6 100644 --- a/apps/desktop/scripts/prepare-runtime.ts +++ b/apps/desktop/scripts/prepare-runtime.ts @@ -1,14 +1,15 @@ -/** Prepare the target Electron distribution and pinned pnpm CLI. */ +/** Prepare Desktop resources and publish their versions after every preparation stage succeeds. */ -import { packagingStep } from './packaging-step.mjs' import { execFileSync } from 'node:child_process' -import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { join } from 'node:path' import { parseArgs } from 'node:util' import { downloadArtifact } from '@electron/get' import extractZip from 'extract-zip' -import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { readPrimaryRuntime } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' +import { desktopTargetPlatform, resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { packagingStep } from './packaging-step.mjs' import { preparePrimaryRuntime } from './prepare-primary-runtime.ts' import { prepareDesktopCli } from './prepare-cli.ts' import { prepareCommandLink } from './prepare-command-link.ts' @@ -16,11 +17,10 @@ import { prepareCommandLink } from './prepare-command-link.ts' const BUILD_PATHS = resolveDesktopTargetBuildPaths() const RUNTIME_ROOT = BUILD_PATHS.runtime -async function main(): Promise { - const { values } = parseArgs({ options: { 'defer-primary-runtime-smoke': { type: 'boolean', default: false } } }) - const target = resolveDesktopBuildTarget() - const platform = target.startsWith('mac-') ? 'darwin' : 'win32' - const arch = target.endsWith('arm64') ? 'arm64' : 'x64' +type DesktopTarget = ReturnType + +/** Extract the target Electron distribution and return its embedded Node version. */ +async function prepareElectron({ platform, arch }: DesktopTarget): Promise { const require = createRequire(import.meta.url) const { version } = require('electron/package.json') as { version: string } const archive = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'download:electron', @@ -28,29 +28,42 @@ async function main(): Promise { rmSync(BUILD_PATHS.electron, { recursive: true, force: true }) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'extract:electron', () => extractZip(archive, { dir: BUILD_PATHS.electron })) const executable = join(BUILD_PATHS.electron, platform === 'win32' ? 'electron.exe' : 'Electron.app/Contents/MacOS/Electron') - const nodeVersion = execFileSync(executable, ['-p', 'process.versions.node'], { + return execFileSync(executable, ['-p', 'process.versions.node'], { encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, }).trim() - const macosMinimumVersion = platform === 'darwin' ? execFileSync('/usr/libexec/PlistBuddy', - ['-c', 'Print LSMinimumSystemVersion', join(BUILD_PATHS.electron, 'Electron.app', 'Contents', 'Info.plist')], { encoding: 'utf8' }).trim() : undefined - rmSync(RUNTIME_ROOT, { recursive: true, force: true }) - mkdirSync(RUNTIME_ROOT, { recursive: true }) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime', - () => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] })) - const manifest = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'primary-runtime', 'runtime.json'), 'utf8')) as { pnpm?: unknown } - if (typeof manifest.pnpm !== 'string') throw new Error('desktop runtime: primary-runtime manifest has no pnpm version') +} + +function prepareCli({ platform, arch }: DesktopTarget): void { cpSync(join(import.meta.dirname, 'node-bin'), join(RUNTIME_ROOT, 'bin'), { recursive: true }) chmodSync(join(RUNTIME_ROOT, 'bin', 'node'), 0o755) + const cli = join(RUNTIME_ROOT, 'cli') + prepareDesktopCli(cli, platform) + if (platform === 'darwin') { + const minimumVersion = execFileSync('/usr/libexec/PlistBuddy', [ + '-c', 'Print LSMinimumSystemVersion', join(BUILD_PATHS.electron, 'Electron.app', 'Contents', 'Info.plist'), + ], { encoding: 'utf8' }).trim() + prepareCommandLink(cli, arch, minimumVersion) + } + cpSync(join(import.meta.dirname, '..', 'lib', 'command-manager-entry.js'), join(cli, 'command-manager.js')) + cpSync(join(import.meta.dirname, 'command-path.ps1'), join(cli, 'command-path.ps1')) +} + +async function main(): Promise { + const { values } = parseArgs({ options: { 'defer-primary-runtime-smoke': { type: 'boolean', default: false } } }) + const target = desktopTargetPlatform(resolveDesktopBuildTarget()) + rmSync(RUNTIME_ROOT, { recursive: true, force: true }) + mkdirSync(RUNTIME_ROOT, { recursive: true }) + const nodeVersion = await prepareElectron(target) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime', + () => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] })) + const { pnpm } = await readPrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')) + if (pnpm === undefined) throw new Error('desktop runtime: primary-runtime manifest has no pnpm version') + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli', async () => { prepareCli(target) }) writeFileSync(join(RUNTIME_ROOT, 'versions.json'), `${JSON.stringify({ schemaVersion: 1, node: nodeVersion, - pnpm: manifest.pnpm, + pnpm, }, undefined, 2)}\n`) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli', - async () => prepareDesktopCli(join(RUNTIME_ROOT, 'cli'), platform)) - if (macosMinimumVersion !== undefined) prepareCommandLink(join(RUNTIME_ROOT, 'cli'), arch, macosMinimumVersion) - cpSync(join(import.meta.dirname, '..', 'lib', 'command-manager-entry.js'), join(RUNTIME_ROOT, 'cli', 'command-manager.js')) - cpSync(join(import.meta.dirname, 'command-path.ps1'), join(RUNTIME_ROOT, 'cli', 'command-path.ps1')) } await main() diff --git a/apps/desktop/tests/runtime-preparation.spec.ts b/apps/desktop/tests/runtime-preparation.spec.ts index 6a4b25a493..d16dd592c2 100644 --- a/apps/desktop/tests/runtime-preparation.spec.ts +++ b/apps/desktop/tests/runtime-preparation.spec.ts @@ -1,58 +1,120 @@ -/** Desktop preparation publishes one package manager alongside its private Node launchers. */ +/** Desktop resource preparation publishes versions only for complete resources. */ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' -const state = vi.hoisted(() => ({ root: '' })) -vi.mock('../scripts/desktop-build-paths.mjs', () => ({ - resolveDesktopBuildTarget: () => 'mac-arm64', +const state = vi.hoisted(() => ({ + root: '', + target: 'mac-arm64' as 'mac-arm64' | 'mac-x64' | 'win-x64', + pnpm: '11.7.0' as string | undefined, + failure: undefined as 'electron' | 'primary' | 'cli' | undefined, + preparePrimary: vi.fn(), + commandLink: vi.fn(), +})) +vi.mock('../scripts/desktop-build-paths.mjs', async importOriginal => ({ + ...await importOriginal(), + resolveDesktopBuildTarget: () => state.target, resolveDesktopTargetBuildPaths: () => ({ runtime: join(state.root, 'runtime'), electron: join(state.root, 'electron'), downloads: join(state.root, 'downloads'), }), })) vi.mock('@electron/get', () => ({ downloadArtifact: async () => 'electron.zip' })) -vi.mock('extract-zip', () => ({ default: async () => {} })) -vi.mock('node:child_process', () => ({ execFileSync: () => '24.18.1\n' })) -vi.mock('../scripts/prepare-command-link.ts', () => ({ prepareCommandLink: () => {} })) -// The source-plane preparation spec supplies the one compiled CLI input; other copies use real files. +vi.mock('extract-zip', () => ({ default: async () => { + if (state.failure === 'electron') throw new Error('Electron extraction failed') +} })) +vi.mock('node:child_process', async importOriginal => ({ + ...await importOriginal(), + execFileSync: (file: string) => file === '/usr/libexec/PlistBuddy' ? '12.0\n' : '24.18.1\n', +})) +vi.mock('../scripts/prepare-command-link.ts', () => ({ prepareCommandLink: state.commandLink })) +// The compiled command manager is a build input; all source resource copies use real files. vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { ...actual, cpSync: (...args: Parameters) => { - if (String(args[0]).endsWith(join('lib', 'command-manager-entry.js'))) actual.writeFileSync(args[1], '') - else actual.cpSync(...args) + if (String(args[0]).endsWith(join('lib', 'command-manager-entry.js'))) { + if (state.failure === 'cli') throw new Error('command manager copy failed') + actual.writeFileSync(args[1], 'command manager fixture') + } else actual.cpSync(...args) } } }) vi.mock('../scripts/prepare-primary-runtime.ts', () => ({ - preparePrimaryRuntime: async () => { + preparePrimaryRuntime: state.preparePrimary, +})) + +const originalArgv = process.argv +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + state.root = mkdtempSync(join(tmpdir(), 'desktop-runtime-preparation-')) + state.target = 'mac-arm64' + state.pnpm = '11.7.0' + state.failure = undefined + process.argv = [process.execPath, 'prepare-runtime.ts'] + vi.stubEnv('DSH_DESKTOP_PACKAGING_RUN_DIR', undefined) + state.preparePrimary.mockImplementation(async () => { + if (state.failure === 'primary') throw new Error('primary preparation failed') const root = join(state.root, 'runtime', 'primary-runtime') const pnpm = join(root, 'dependencies', 'pnpm', 'bin') mkdirSync(pnpm, { recursive: true }) writeFileSync(join(pnpm, 'pnpm.mjs'), '') - writeFileSync(join(root, 'runtime.json'), JSON.stringify({ pnpm: '11.7.0' })) - }, -})) - -afterEach(() => { - vi.unstubAllGlobals() - vi.unstubAllEnvs() - rmSync(state.root, { recursive: true, force: true }) + writeFileSync(join(root, 'runtime.json'), JSON.stringify({ + desktopVersion: '1.0.0', platform: state.target === 'win-x64' ? 'win32' : 'darwin', + arch: state.target === 'mac-arm64' ? 'arm64' : 'x64', + python: '3.12.14', node: '24.17.0', pnpm: state.pnpm, pythonPackages: { numpy: '2.3.5' }, + })) + }) }) -it('replaces stale resources with one pnpm distribution and records its version', async () => { - state.root = mkdtempSync(join(tmpdir(), 'desktop-runtime-preparation-')) +afterEach(async () => { + process.argv = originalArgv + vi.unstubAllEnvs() + await rm(state.root, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) +}) + +it.each(['mac-arm64', 'mac-x64', 'win-x64'] as const)('prepares %s with one pnpm distribution and its CLI resources', async (target) => { + state.target = target + const windows = target === 'win-x64' + if (windows) process.argv.push('--defer-primary-runtime-smoke') const runtime = join(state.root, 'runtime') mkdirSync(join(runtime, 'pnpm'), { recursive: true }) - vi.stubGlobal('process', { ...process, argv: [process.execPath, 'prepare-runtime.ts'] }) - vi.stubEnv('DSH_DESKTOP_PACKAGING_RUN_DIR', undefined) await import('../scripts/prepare-runtime.ts') expect(existsSync(join(runtime, 'pnpm'))).toBe(false) expect(existsSync(join(runtime, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'))).toBe(true) expect(existsSync(join(runtime, 'bin', 'node'))).toBe(true) - expect(existsSync(join(runtime, 'cli', 'bin', 'dsh'))).toBe(true) - expect(existsSync(join(runtime, 'cli', 'command-manager.js'))).toBe(true) + expect(existsSync(join(runtime, 'cli', 'bin', windows ? 'dsh.cmd' : 'dsh'))).toBe(true) + expect(readFileSync(join(runtime, 'cli', 'command-manager.js'), 'utf8')).toBe('command manager fixture') + expect(existsSync(join(runtime, 'cli', 'command-path.ps1'))).toBe(true) expect(JSON.parse(readFileSync(join(runtime, 'versions.json'), 'utf8'))).toEqual({ schemaVersion: 1, node: '24.18.1', pnpm: '11.7.0', }) + expect(state.preparePrimary).toHaveBeenCalledWith({ deferSmoke: windows }) + if (windows) expect(state.commandLink).not.toHaveBeenCalled() + else expect(state.commandLink).toHaveBeenCalledWith(join(runtime, 'cli'), target === 'mac-arm64' ? 'arm64' : 'x64', '12.0') +}) + +it.each([ + ['electron', 'Electron extraction failed'], + ['primary', 'primary preparation failed'], + ['cli', 'command manager copy failed'], +] as const)('does not publish versions when %s preparation fails', async (failure, error) => { + state.failure = failure + const runtime = join(state.root, 'runtime') + mkdirSync(runtime) + writeFileSync(join(runtime, 'versions.json'), 'stale versions') + await expect(import('../scripts/prepare-runtime.ts')).rejects.toThrow(error) + expect(existsSync(join(runtime, 'versions.json'))).toBe(false) +}) + +it.each([ + ['not-a-version', 'primary runtime: invalid metadata'], + [undefined, 'primary-runtime manifest has no pnpm version'], +])('rejects pnpm metadata %s even when Windows smoke is deferred', async (pnpm, error) => { + state.target = 'win-x64' + process.argv.push('--defer-primary-runtime-smoke') + state.pnpm = pnpm + await expect(import('../scripts/prepare-runtime.ts')).rejects.toThrow(error) + expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false) }) From 18f00612c67c6ca0b73d5745eb8f0c4479dc04af Mon Sep 17 00:00:00 2001 From: kingwl Date: Wed, 30 Sep 2026 19:39:54 +0800 Subject: [PATCH 3/3] fix(desktop): use shared pnpm in the installed CLI --- apps/desktop-host/src/cli.ts | 4 +-- apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/smoke-runtime.ts | 32 +++++++++++++++++----- apps/desktop/tests/desktop-cli.spec.ts | 37 ++++++++++++++++++++++++++ 6 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/tests/desktop-cli.spec.ts diff --git a/apps/desktop-host/src/cli.ts b/apps/desktop-host/src/cli.ts index 97a23b88a2..6c890f8990 100644 --- a/apps/desktop-host/src/cli.ts +++ b/apps/desktop-host/src/cli.ts @@ -7,7 +7,7 @@ import { installOfficeEngineResolution, runtimeArchivePath } from './office-engi /** * Run the ordinary CLI with Desktop's bundled package manager and reserved-profile plugin access. * @param runtimeDir - Prepared or ASAR-contained production DSH package tree. - * @param supportDir - Physical Desktop runtime directory containing pnpm. + * @param supportDir - Physical Desktop runtime directory containing the primary-runtime payload and Node launchers. * @returns Completion of the selected CLI command; profile plugins own their process lifetime. */ export async function runDesktopCli(runtimeDir: string, supportDir: string): Promise { @@ -16,7 +16,7 @@ export async function runDesktopCli(runtimeDir: string, supportDir: string): Pro manageDesktopProfile: true, packageManager: { command: process.execPath, - args: ['--expose-internals', join(supportDir, 'pnpm', 'bin', 'pnpm.mjs')], + args: ['--expose-internals', join(supportDir, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')], env: { ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 1e5af2cd9a..d547d671d8 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md /deepseek-harness-desktop: - en: ce9e0f6a865f086c - zh: e41fbe521e087f57 + en: 6561db5aa4fedced + zh: 5c6e86f1463f2b1c /deepseek-harness-desktop/terminal-command: en: bef92953c237363f zh: d21d6effd21b8c00 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 9ed6b94d11..9d87e9894e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -10,7 +10,7 @@ The first application-menu command, **About DeepSeek Harness**, opens Electron's Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. -Desktop ships one pnpm distribution at `resources/runtime/primary-runtime/dependencies/pnpm`. Build-time production installation, Creator and the Web Plugin Manager execute its `bin/pnpm.mjs` with Electron Node mode; package operations do not require pnpm on PATH. The private Node launcher environment applies only to package operations. Agent workspace dependencies copy the same self-contained primary-runtime payload into the Harness home and execute pnpm with its standalone Node. +Desktop ships one pnpm distribution at `resources/runtime/primary-runtime/dependencies/pnpm`. Build-time production installation, the installed `dsh` CLI, Creator and the Web Plugin Manager execute its `bin/pnpm.mjs` with Electron Node mode; package operations do not require pnpm on PATH. The private Node launcher environment applies only to package operations. Agent workspace dependencies copy the same self-contained primary-runtime payload into the Harness home and execute pnpm with its standalone Node. Embedded Platform documents use a persistent WebContentsView partition keyed by a hash of the Platform origin and stable account ID. localStorage preferences, including dismissed notices, survive view closure and application restart; different accounts and origins use separate storage. The Host supplies the account ID from its latest successful profile read; while no ID is known, the document opens in a disposable partition that keeps no preference across opens. Opening a persistent partition clears cookies, filesystem, IndexedDB, Cache Storage, the HTTP and shader caches, service workers, and HTTP authentication before the document loads; a disposable partition clears all of its storage. Closing a view destroys the document, removes its request interceptors, and schedules the same cleanup, so authentication left by an unclean exit is cleared before the next document loads. The next open and application shutdown await that cleanup, and an update install waits for it before the installer takes over the exit. A failed cleanup rejects that open and blocks the same account until a later cleanup succeeds; other accounts keep opening. Sign-out destroys the document but retains account-scoped preferences for the next sign-in. A document already open in a disposable partition stays mounted when the account ID arrives for the same credential; the next open uses the account-scoped partition. The [storage decision](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.md) describes the retention policy. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode, synchronous getAuthToken() and getLocale() getters, and onLocaleChange(listener), which returns an unsubscribe function. Both getters read preload memory without further IPC. The bootstrap includes the resolved Desktop language (`zh_CN` or `en_US`); Settings language changes update the preload cache and notify the open Platform document without reloading. Platform applies this language before its first render and does not persist it as a browser preference. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index df9ade6b31..76a8cbd528 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -10,7 +10,7 @@ Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 -Desktop 在 `resources/runtime/primary-runtime/dependencies/pnpm` 中携带一份 pnpm 分发包。构建时的生产依赖安装、Creator 和 Web 插件管理器通过 Electron Node 模式执行其中的 `bin/pnpm.mjs`;包操作不要求 PATH 上存在 pnpm。私有 Node 启动器环境仅用于包操作。agent(智能体)的工作区依赖将同一份完整 primary-runtime 复制到 Harness 主目录,并使用其中的独立 Node 执行 pnpm。 +Desktop 在 `resources/runtime/primary-runtime/dependencies/pnpm` 中携带一份 pnpm 分发包。构建时的生产依赖安装、安装版 `dsh` CLI、Creator 和 Web 插件管理器通过 Electron Node 模式执行其中的 `bin/pnpm.mjs`;包操作不要求 PATH 上存在 pnpm。私有 Node 启动器环境仅用于包操作。agent(智能体)的工作区依赖将同一份完整 primary-runtime 复制到 Harness 主目录,并使用其中的独立 Node 执行 pnpm。 Platform 内嵌文档使用持久化 WebContentsView 分区,分区名由 Platform 来源和稳定账号 ID 的哈希决定。localStorage 中的页面偏好(包括已关闭的通知)在关闭视图和重启应用后保留;不同账号和来源使用独立存储。账号 ID 来自 Host 最近一次成功的资料读取;尚无该 ID 时,文档在一次性分区中打开,该分区不跨次保留偏好。打开持久分区会先清理 Cookie、文件系统、IndexedDB、Cache Storage、HTTP 与着色器缓存、Service Worker 及 HTTP 认证状态;一次性分区则清理其全部存储。关闭视图会销毁文档、移除请求拦截器并安排相同的清理,因此异常退出遗留的认证会在下一个文档加载前被清除。下次打开和应用退出都会等待该清理完成,更新安装也会在安装器接管退出前等待。清理失败会使该次打开失败,并在后续清理成功前阻止同一账号打开;其他账号不受影响。退出登录会销毁文档,但保留账号偏好供下次登录使用。同一凭证下账号 ID 迟到时,已以一次性分区打开的文档保持挂载;下次打开使用账号分区。[存储决策](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.zh.md)说明保留策略。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode、同步的 getAuthToken() 和 getLocale() getter,以及返回取消订阅函数的 onLocaleChange(listener)。两个 getter 都只读取 preload 内存,不再调用 IPC。bootstrap 包含 Desktop 已解析的语言(`zh_CN` 或 `en_US`);Settings 语言变更会更新 preload 缓存并通知已打开的 Platform 文档,无需重载。Platform 在首屏渲染前应用该语言,且不将其持久化为浏览器偏好。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。 diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index f968a9c21b..dec4f8fbc6 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -1,6 +1,7 @@ /** Boot the materialized target runtime without access to a user's Harness profile. */ -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { execFile } from 'node:child_process' @@ -12,7 +13,7 @@ import { createPluginProfile } from '../src/project-manager.ts' import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' /** - * Check Host startup, its matching frontend, external plugins and real Office-to-PDF conversion. + * Check CLI package operations, Host startup, its frontend, external plugins and real Office-to-PDF conversion. * @param root - Materialized dsh resources. * @param node - Prepared target Electron executable. * @param runtime - Verified resource descriptor. @@ -29,12 +30,16 @@ export async function smokeDesktopRuntime( ...Object.fromEntries(Object.entries(environment).filter(([name]) => !/^(?:npm|pnpm|corepack)_/iu.test(name))), DSH_HOME: home, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home, XDG_CACHE_HOME: home, XDG_STATE_HOME: home, + APPDATA: home, LOCALAPPDATA: home, + npm_config_userconfig: join(home, 'npmrc'), npm_config_globalconfig: join(home, 'npmrc'), + npm_config_store_dir: join(home, 'store'), npm_config_enable_global_virtual_store: 'false', } const host = new DesktopHostProcess(node, root, profile, undefined, hostEnvironment, undefined, join(resourcesRuntime, 'primary-runtime'), { pnpm: join(resourcesRuntime, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'), nodeBin: join(resourcesRuntime, 'bin') }) let timer: ReturnType | undefined try { + writeFileSync(join(home, 'npmrc'), '') createPluginProfile(profile) const pluginName = 'desktop-runtime-smoke-plugin' const plugin = join(home, pluginName) @@ -52,7 +57,19 @@ export function apply(ctx) { } `) writeFileSync(join(installedPlugin, 'bundle.yml'), `- insert:\n - id: ${installedName}\n name: ${installedName}\n inject: [webServer]\n`) - writeFileSync(join(profile, 'pnpm-workspace.yaml'), 'nodeLinker: hoisted\nautoInstallPeers: false\noffline: true\nupdateNotifier: false\n') + appendFileSync(join(profile, 'pnpm-workspace.yaml'), 'offline: true\nupdateNotifier: false\n') + const cli = join(root, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js') + const runCli = (args: string[]) => promisify(execFile)(node, + ['--expose-internals', cli, 'plugin', '--profile', 'desktop', ...args], + { cwd: home, env: { ...hostEnvironment, ELECTRON_RUN_AS_NODE: '1' }, timeout: 120_000, windowsHide: true }) + await runCli(['add', `file:${installedPlugin}`, '--offline', '--ignore-scripts']) + const installedEntry = join(profile, 'node_modules', installedName, 'index.js') + if (readFileSync(installedEntry, 'utf8') !== readFileSync(join(installedPlugin, 'index.js'), 'utf8')) { + throw new Error('desktop runtime: CLI did not install the local plugin') + } + await runCli(['remove', installedName]) + if (existsSync(installedEntry)) throw new Error('desktop runtime: CLI did not remove the local plugin') + console.log('desktop runtime: CLI offline plugin installation and removal passed') const primary = join(resourcesRuntime, 'primary-runtime') const dependencies = workspaceDependencyPaths(primary, await readPrimaryRuntime(primary)) await promisify(execFile)(dependencies.python, ['-I', '-B', @@ -121,10 +138,10 @@ export function apply(ctx) { writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills, pluginManager]\n') cpSync(plugin, join(profile, 'node_modules', pluginName), { recursive: true }) const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { - dependencies: Record + dependencies?: Record dsh: { profile: { bundles: string[] } } } - manifest.dependencies[pluginName] = `file:${plugin}` + manifest.dependencies = { ...manifest.dependencies, [pluginName]: `file:${plugin}` } manifest.dsh.profile.bundles.push(pluginName) writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest)) writeFileSync(join(profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n') @@ -170,7 +187,8 @@ export function apply(ctx) { console.log('desktop runtime: DOCX, XLSX, PPTX to PDF and skill CLI discovery passed') } finally { clearTimeout(timer) - await host.stop() - rmSync(home, { recursive: true, force: true }) + try { await host.stop() } finally { + await rm(home, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) + } } } diff --git a/apps/desktop/tests/desktop-cli.spec.ts b/apps/desktop/tests/desktop-cli.spec.ts new file mode 100644 index 0000000000..e358df8657 --- /dev/null +++ b/apps/desktop/tests/desktop-cli.spec.ts @@ -0,0 +1,37 @@ +/** The installed CLI uses the shared pnpm distribution with Electron's package-operation environment. */ + +import { delimiter, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { runCli } from '@deepseek-ai/dsh/lib/bin.js' +import { runDesktopCli } from '../../desktop-host/src/cli.ts' +import { installOfficeEngineResolution } from '../../desktop-host/src/office-engine.ts' + +vi.mock('@deepseek-ai/dsh/lib/bin.js', () => ({ runCli: vi.fn() })) +vi.mock('../../desktop-host/src/office-engine.ts', () => ({ installOfficeEngineResolution: vi.fn() })) + +afterEach(() => { + vi.unstubAllEnvs() + vi.clearAllMocks() +}) + +it.each(['system-bin', undefined])('dispatches with bundled pnpm when PATH is %s', async (path) => { + vi.stubEnv('PATH', path) + const runtime = join('Application 中文 with spaces', 'resources', 'app.asar', 'dsh') + const support = join('Application 中文 with spaces', 'resources', 'runtime') + + await runDesktopCli(runtime, support) + + expect(installOfficeEngineResolution).toHaveBeenCalledExactlyOnceWith(runtime) + expect(runCli).toHaveBeenCalledExactlyOnceWith({ + manageDesktopProfile: true, + packageManager: { + command: process.execPath, + args: ['--expose-internals', join(support, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')], + env: { + ELECTRON_RUN_AS_NODE: '1', + DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, + PATH: `${join(support, 'bin')}${delimiter}${path ?? ''}`, + }, + }, + }) +})