From dfc175576ffa3412f5cd6293dd84156bf5aa8013 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Thu, 1 Oct 2026 23:32:46 -0700 Subject: [PATCH 1/2] feat(packaging): load JavaScript from app.asar and unpack only native files Align the package layout with upstream deepseek-harness apps/desktop. Since #646 every consumer of the bundled packages runs on the Electron runtime (main, utility process, Helper in Node mode), which reads app.asar, so unpacking all of node_modules bought nothing: about 21k loose files that slowed installs and put "app.asar" in physical paths for dependencies' path heuristics to trip over. - asarUnpack keeps only native addons/libraries, spawn-helper, ripgrep, the LibreOffice engine and sherpa-onnx platform packages and the PPT runtime (macOS arm64: 1.6k files / 235 MB unpacked, was 21k / 549 MB). - Remove runtimePackageRoot; the bundled runtime root is app.getAppPath(). - build/office-engine-resolution.mjs (adapted from upstream desktop-host office-engine.ts) resolves the LibreOffice engine package to app.asar.unpacked so the OS can spawn its executable; registered in harness-node-entry. - Drop the node-pty patch: upstream node-pty maps app.asar to app.asar.unpacked itself, which is correct in this layout. - afterPack (scripts/after-pack.cjs) now verifies the PPT runtime through app.asar and fails when a Mach-O/ELF/PE file is packed inline. - Windows release smoke loads koffi and pnpm through app.asar. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 5 +- build/harness-node-entry.mjs | 4 ++ build/office-engine-resolution.d.mts | 2 + build/office-engine-resolution.mjs | 50 ++++++++++++++ docs/release-runbook.md | 4 +- package.json | 13 +++- patches/node-pty+1.2.0-beta.15.patch | 16 ----- scripts/after-pack.cjs | 13 ++++ scripts/verify-asar-unpack.cjs | 41 +++++++++++ scripts/verify-packaged-ppt-runtime.cjs | 14 ++-- src/main/index.ts | 9 ++- src/main/runtime-package-root.ts | 4 -- src/main/state/host-plugin-sources.ts | 4 +- test/host-plugin-sources.test.ts | 4 +- ...r.test.ts => node-pty-asar-layout.test.ts} | 16 +++-- test/office-engine-resolution.test.ts | 69 +++++++++++++++++++ test/patch-runtime-compatibility.test.mjs | 2 +- test/ppt-source-build-contract.test.ts | 2 +- test/release.test.ts | 14 +++- test/runtime-package-root.test.ts | 16 ----- test/verify-asar-unpack.test.ts | 46 +++++++++++++ 21 files changed, 284 insertions(+), 64 deletions(-) create mode 100644 build/office-engine-resolution.d.mts create mode 100644 build/office-engine-resolution.mjs delete mode 100644 patches/node-pty+1.2.0-beta.15.patch create mode 100644 scripts/after-pack.cjs create mode 100644 scripts/verify-asar-unpack.cjs delete mode 100644 src/main/runtime-package-root.ts rename test/{node-pty-unpacked-helper.test.ts => node-pty-asar-layout.test.ts} (60%) create mode 100644 test/office-engine-resolution.test.ts delete mode 100644 test/runtime-package-root.test.ts create mode 100644 test/verify-asar-unpack.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91aec892d..51bff7879 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -385,9 +385,10 @@ jobs: return $child.ExitCode } $koffiProbe = "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const require = createRequire(pathToFileURL(process.argv[1])); const resolved = require.resolve('koffi'); if (!resolved.startsWith(process.argv[2])) throw new Error('koffi resolved outside packaged app: ' + resolved); const koffi = require('koffi'); const getCurrentProcessId = koffi.load('kernel32.dll').func('GetCurrentProcessId', 'uint32', []); if (getCurrentProcessId() !== process.pid) throw new Error('Native koffi smoke failed'); console.log('Packaged koffi native binding passed'); process.exit(0);" - $koffiExitCode = Invoke-ElectronNode @('--input-type=module', '-e', $koffiProbe, (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules\koffi\package.json'), (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules')) + $koffiExitCode = Invoke-ElectronNode @('--input-type=module', '-e', $koffiProbe, (Join-Path $isolatedApp 'resources\app.asar\node_modules\koffi\package.json'), (Join-Path $isolatedApp 'resources\app.asar\node_modules')) if ($koffiExitCode -ne 0) { throw "Packaged koffi native binding failed (exit code $koffiExitCode)." } - $pnpmEntry = Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules\pnpm\bin\pnpm.cjs' + # Packages load through app.asar (native files are unpacked beside it). + $pnpmEntry = Join-Path $isolatedApp 'resources\app.asar\node_modules\pnpm\bin\pnpm.cjs' $pnpmExitCode = Invoke-ElectronNode @($pnpmEntry, '--version') if ($pnpmExitCode -ne 0) { throw "Packaged pnpm failed under Electron Node mode (exit code $pnpmExitCode)." } if (Test-Path $userData) { Remove-Item -Recurse -Force $userData } diff --git a/build/harness-node-entry.mjs b/build/harness-node-entry.mjs index cb69ae4e5..c9d394d62 100644 --- a/build/harness-node-entry.mjs +++ b/build/harness-node-entry.mjs @@ -2,6 +2,7 @@ import childProcess from 'node:child_process' import { syncBuiltinESMExports } from 'node:module' import { pathToFileURL } from 'node:url' import { registerHostModuleFallback } from './host-module-fallback.mjs' +import { registerOfficeEngineResolution } from './office-engine-resolution.mjs' import { enforceWindowsChildProcessHide } from './windows-child-process-hide.mjs' // On macOS Harness runs inside an Electron utility process (TCC responsibility @@ -86,6 +87,9 @@ if (!dshEntryPath) { process.argv = [process.execPath, dshEntryPath, ...dshArguments] try { registerHostModuleFallback(dshEntryPath) + // Packages load through app.asar; the Office engine must resolve to its + // unpacked directory so the OS can spawn it. + registerOfficeEngineResolution(dshEntryPath) // Harness 0.1.5 gates its CLI behind `if (import.meta.main)` and exports // `runCli`. This file imports the entry rather than being it, so that guard // is false here and a plain import would load the module, run nothing, and diff --git a/build/office-engine-resolution.d.mts b/build/office-engine-resolution.d.mts new file mode 100644 index 000000000..caa143d7e --- /dev/null +++ b/build/office-engine-resolution.d.mts @@ -0,0 +1,2 @@ +export function packagedArchiveRoot(dshEntryPath: string): string | undefined +export function registerOfficeEngineResolution(dshEntryPath: string): { deregister(): void } | undefined diff --git a/build/office-engine-resolution.mjs b/build/office-engine-resolution.mjs new file mode 100644 index 000000000..a0d1a33ed --- /dev/null +++ b/build/office-engine-resolution.mjs @@ -0,0 +1,50 @@ +import { realpathSync } from 'node:fs' +import { registerHooks } from 'node:module' +import { basename, dirname, join, sep } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const ENGINE_PACKAGE = /^@deepseek-ai\/libreoffice-kit-(?:darwin|win32|linux)-/u + +/** + * The application root that owns `dshEntryPath` + * (`/node_modules/@deepseek-ai/dsh/lib/bin.js`), or undefined when that + * root is not an ASAR archive. + */ +export function packagedArchiveRoot(dshEntryPath) { + const root = dirname(dirname(dirname(dirname(dirname(dshEntryPath))))) + return basename(root) === 'app.asar' ? root : undefined +} + +/** + * Resolve the LibreOfficeKit engine package from app.asar.unpacked. + * + * Packages load through app.asar, but the engine's executable and resources + * are spawned by the operating system, which cannot read the archive. + * libreoffice-kit locates them from the engine package's resolved path, so the + * engine package itself must resolve to its unpacked, physical directory. + * Adapted from deepseek-harness apps/desktop-host/src/office-engine.ts. + * Hooks apply to this thread only. + */ +export function registerOfficeEngineResolution(dshEntryPath) { + const archive = packagedArchiveRoot(dshEntryPath) + if (archive === undefined) return undefined + const engines = join(archive, 'node_modules', '@deepseek-ai', 'libreoffice-kit-') + const source = pathToFileURL(engines).href + const destination = pathToFileURL(join(`${archive}.unpacked`, 'node_modules', '@deepseek-ai', 'libreoffice-kit-')).href + const archivePrefix = pathToFileURL(archive + sep).href + return registerHooks({ + resolve(specifier, context, nextResolve) { + const resolved = nextResolve(specifier, context) + if (!ENGINE_PACKAGE.test(specifier) || !resolved.url.startsWith('file:')) return resolved + const canonical = pathToFileURL(realpathSync(fileURLToPath(resolved.url))).href + if (!canonical.startsWith(source)) { + if (canonical.startsWith(archivePrefix)) { + throw new Error(`Office engine resolved outside the packaged engine directory: ${resolved.url}`) + } + return resolved + } + const physical = realpathSync(fileURLToPath(destination + canonical.slice(source.length))) + return { ...resolved, url: pathToFileURL(physical).href } + } + }) +} diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 8bb8e5f8c..4f77e026f 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -22,7 +22,7 @@ Concurrency is grouped by ref and target: a Windows-only retry can run while an The self-hosted signer downloads artifacts in six concurrent 32 MiB ranges through `gh`, retries bounded requests, and checks the complete archive against GitHub's SHA-256 digest before extraction. A real 668,014,109-byte signing artifact downloaded and verified in 149 seconds on the signing host; the previous single stream was still incomplete after ten minutes. Throughput depends on the network. A short response, failed transfer or digest mismatch leaves an existing verified output untouched and removes temporary parts. -PPT packages are generated under `.build/ppt-runtime/packages/` and overlaid into the Electron package. The `afterPack` gate checks the physical `dsh-ppt` and `dsh-ppt-composer` directories, including native imports and template previews. A successful source build alone does not verify the packaged paths. +PPT packages are generated under `.build/ppt-runtime/packages/` and overlaid into the Electron package. The `afterPack` gate (`scripts/after-pack.cjs`) loads `dsh-ppt` and `dsh-ppt-composer` through `app.asar` on the packaged Electron runtime, including native imports and template previews, and fails when any Mach-O, ELF or PE file is packed inside `app.asar` instead of being matched by `asarUnpack`. A successful source build alone does not verify the packaged paths. ## Local Windows UKey signing runner @@ -30,7 +30,7 @@ Windows packaging and signing run as separate jobs. The GitHub-hosted Windows ru The pinned Windows NSIS template stages the application in a sibling directory before closing the old app, then renames the old directory to a backup and promotes the staged directory. A failed extraction or rename restores the previous installation. The signed installer smoke also locks the old executable to verify that a failed upgrade leaves it runnable. A user-selected different directory is an independent installation: the installer does not automatically uninstall the previous directory, which remains available until the user removes it. Keep the user-selected installation directory when changing this template; the build adapter rejects unexpected upstream template changes. -Runtime dependencies remain unpacked beside `app.asar` because Harness, pnpm, native addons, and plugin generation installation need physical paths. The macOS ARM64 test package contains a 6.2 MB `app.asar` and about 588 MB of unpacked dependencies; enabling asar alone did not reduce its 256 MB DMG. Neither platform ships an independent Node: Windows uses the packaged Electron executable in Node mode, and macOS runs package commands through the app's Helper in Node mode. The locked Electron 43.0.0 is a native-loader supported fingerprint; an earlier Electron 43.4.0 attempt failed during Harness boot even though the Koffi probe passed ([Windows CI evidence](https://github.com/dataelement/dsh-desktop/actions/runs/35972303467)). Qualify any Electron or native-loader change with packaged Windows Harness and final signed-installer gates. +JavaScript dependencies load from `app.asar`: Harness, pnpm and package commands all run on the Electron runtime, which reads the archive. `asarUnpack` keeps only what the OS loads or executes beside it (native addons and libraries, node-pty's `spawn-helper`, ripgrep, the LibreOffice engine and sherpa-onnx platform packages, and the PPT runtime). node-pty and ripgrep map their binaries to `app.asar.unpacked` themselves; `build/office-engine-resolution.mjs` resolves the LibreOffice engine package there. The macOS ARM64 test package unpacks about 1.6k files (235 MB) instead of about 21k (549 MB). Neither platform ships an independent Node: Windows uses the packaged Electron executable in Node mode, and macOS runs package commands through the app's Helper in Node mode. The locked Electron 43.0.0 is a native-loader supported fingerprint; an earlier Electron 43.4.0 attempt failed during Harness boot even though the Koffi probe passed ([Windows CI evidence](https://github.com/dataelement/dsh-desktop/actions/runs/35972303467)). Qualify any Electron or native-loader change with packaged Windows Harness and final signed-installer gates. Prepare the local runner once: diff --git a/package.json b/package.json index 48686c66e..0074dcd08 100644 --- a/package.json +++ b/package.json @@ -311,7 +311,12 @@ "productName": "DSH Desktop", "asar": true, "asarUnpack": [ - "node_modules/**/*", + "**/*.{node,dylib,dll,so,exe}", + "**/*.so.*", + "**/spawn-helper", + "**/@vscode/ripgrep-*/bin/rg", + "node_modules/@deepseek-ai/libreoffice-kit-*/**/*", + "node_modules/sherpa-onnx-*/**/*", ".build/ppt-runtime/packages/**/*" ], "npmRebuild": false, @@ -388,6 +393,10 @@ "from": "build/host-module-fallback.mjs", "to": "host-module-fallback.mjs" }, + { + "from": "build/office-engine-resolution.mjs", + "to": "office-engine-resolution.mjs" + }, { "from": "build/windows-hidden-console.mjs", "to": "windows-hidden-console.mjs" @@ -485,6 +494,6 @@ "createDesktopShortcut": true, "createStartMenuShortcut": true }, - "afterPack": "scripts/verify-packaged-ppt-runtime.cjs" + "afterPack": "scripts/after-pack.cjs" } } diff --git a/patches/node-pty+1.2.0-beta.15.patch b/patches/node-pty+1.2.0-beta.15.patch deleted file mode 100644 index 2a1207f72..000000000 --- a/patches/node-pty+1.2.0-beta.15.patch +++ /dev/null @@ -1,16 +0,0 @@ -diff --git a/node_modules/node-pty/lib/unixTerminal.js b/node_modules/node-pty/lib/unixTerminal.js -index af2d24c..f260b49 100644 ---- a/node_modules/node-pty/lib/unixTerminal.js -+++ b/node_modules/node-pty/lib/unixTerminal.js -@@ -30,7 +30,10 @@ var native = (0, utils_1.loadNativeModule)('pty'); - var pty = native.module; - var helperPath = native.dir + '/spawn-helper'; - helperPath = path.resolve(__dirname, helperPath); --helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); -+// DSH Desktop: Harness loads packages from the physical app.asar.unpacked path, -+// which the plain replace turned into app.asar.unpacked.unpacked (ENOENT from -+// posix_spawn). Remove once upstream node-pty skips already-unpacked paths. -+helperPath = helperPath.replace(/app\.asar(?!\.unpacked)/, 'app.asar.unpacked'); - helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); - var DEFAULT_FILE = 'sh'; - var DEFAULT_NAME = 'xterm'; diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs new file mode 100644 index 000000000..8353f3403 --- /dev/null +++ b/scripts/after-pack.cjs @@ -0,0 +1,13 @@ +const path = require('node:path') +const verifyPackagedPptRuntime = require('./verify-packaged-ppt-runtime.cjs') +const { verifyAsarUnpack } = require('./verify-asar-unpack.cjs') + +/** electron-builder afterPack: package-content gates run before signing. */ +module.exports = async function afterPack(context) { + const product = context.packager.appInfo.productFilename + const resourcesDir = context.electronPlatformName === 'darwin' || context.electronPlatformName === 'mas' + ? path.join(context.appOutDir, `${product}.app`, 'Contents', 'Resources') + : path.join(context.appOutDir, 'resources') + verifyAsarUnpack(resourcesDir) + await verifyPackagedPptRuntime(context) +} diff --git a/scripts/verify-asar-unpack.cjs b/scripts/verify-asar-unpack.cjs new file mode 100644 index 000000000..7ae80136b --- /dev/null +++ b/scripts/verify-asar-unpack.cjs @@ -0,0 +1,41 @@ +const path = require('node:path') +const asar = require('@electron/asar') + +// First bytes of the executable formats the OS loads directly: ELF, Mach-O +// (32/64-bit, both byte orders, universal), and PE (checked further below). +const ELF = Buffer.from([0x7f, 0x45, 0x4c, 0x46]) +const MACHO = [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca] + +/** Whether bytes start a file the OS, not Electron, must read from disk. */ +function nativeFormat(bytes) { + if (bytes.length < 4) return undefined + if (bytes.subarray(0, 4).equals(ELF)) return 'ELF' + if (MACHO.includes(bytes.readUInt32BE(0))) return 'Mach-O' + if (bytes[0] === 0x4d && bytes[1] === 0x5a && bytes.length >= 0x40) { + const offset = bytes.readUInt32LE(0x3c) + if (offset + 4 <= bytes.length && bytes.toString('latin1', offset, offset + 4) === 'PE\0\0') return 'PE' + } + return undefined +} + +/** + * Fail packaging when app.asar holds a native binary inline. The archive is read + * only through Electron; dlopen and process spawning need a real file, so every + * such binary must be matched by `asarUnpack`. + */ +function verifyAsarUnpack(resourcesDir) { + const archive = path.join(resourcesDir, 'app.asar') + const inline = [] + for (const entry of asar.listPackage(archive, { isPack: false })) { + const relative = entry.replace(/^[\\/]/u, '') + const info = asar.statFile(archive, relative, false) + if (!('size' in info) || info.unpacked || info.link !== undefined || info.size < 4) continue + const format = nativeFormat(asar.extractFile(archive, relative)) + if (format !== undefined) inline.push(`${relative} (${format})`) + } + if (inline.length > 0) { + throw new Error(`Native binaries packed inside app.asar; add them to asarUnpack:\n ${inline.join('\n ')}`) + } +} + +module.exports = { nativeFormat, verifyAsarUnpack } diff --git a/scripts/verify-packaged-ppt-runtime.cjs b/scripts/verify-packaged-ppt-runtime.cjs index d9875b9e4..1cee22b16 100644 --- a/scripts/verify-packaged-ppt-runtime.cjs +++ b/scripts/verify-packaged-ppt-runtime.cjs @@ -63,24 +63,24 @@ async function verifyRuntime(appRoot) { module.exports = async function verifyPackagedPptRuntime(context) { const product = context.packager.appInfo.productFilename const macContents = path.join(context.appOutDir, product + '.app', 'Contents') + // Verify through the path the Desktop loads packages from: app.asar, read by + // the Electron runtime, with native files served from app.asar.unpacked. const candidates = [ + path.join(context.appOutDir, 'resources', 'app.asar'), + path.join(macContents, 'Resources', 'app.asar'), path.join(context.appOutDir, 'resources', 'app'), - path.join(macContents, 'Resources', 'app'), - path.join(context.appOutDir, 'resources', 'app.asar.unpacked'), - path.join(macContents, 'Resources', 'app.asar.unpacked') + path.join(macContents, 'Resources', 'app') ] const appRoot = candidates.find(candidate => require('node:fs').existsSync(candidate)) - if (!appRoot) throw new Error('Cannot locate the packaged physical runtime') + if (!appRoot) throw new Error('Cannot locate the packaged application runtime') // The packaged Electron executable is the only Node runtime the app ships; // macOS runs Node work through its Helper, as the Desktop does. const executable = process.platform === 'win32' ? path.join(context.appOutDir, product + '.exe') : path.join(macContents, 'Frameworks', product + ' Helper.app', 'Contents', 'MacOS', product + ' Helper') if (!require('node:fs').existsSync(executable)) throw new Error('Cannot locate the packaged Electron executable') - // Harness needs physical package paths, so verify appRoot (the unpacked - // directory) rather than paths inside app.asar. await execFileAsync(executable, [__filename, '--verify-runtime', appRoot], { - cwd: appRoot, + cwd: path.dirname(appRoot), env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, timeout: 120_000, maxBuffer: 1024 * 1024 diff --git a/src/main/index.ts b/src/main/index.ts index 0cdb5f433..8b7af0d3a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,5 @@ import { initializeDesktopService, desktopDiagnostics } from './desktop-service' import { applyMacosWindowBackdrop } from './macos-window-backdrop' -import { runtimePackageRoot } from './runtime-package-root' import { checkBlockingPluginUpdates, selectPluginRecoveryTarget, PluginRecoveryEvidence, planPluginRecovery, runPluginRecoveryPlan, type PluginRecoveryCheck } from './plugin-recovery-market' import { RepairAgentService, type CrashEvidence } from './repair-agent' import { spawn } from 'node:child_process' @@ -541,8 +540,14 @@ async function syncNativeTheme(window: BrowserWindow): Promise { applyWindowChromeTheme(window, isDark) } +/** + * Where the bundled Harness and its packages load from: app.asar when packaged. + * Every consumer runs on the Electron runtime, which reads the archive; native + * files the OS executes are unpacked and reached through their own resolution + * (node-pty, ripgrep, and the Office engine hook in harness-node-entry). + */ function bundledRuntimeRoot(): string { - return runtimePackageRoot(app.getAppPath(), app.isPackaged) + return app.getAppPath() } function dshEntryPath(): string { diff --git a/src/main/runtime-package-root.ts b/src/main/runtime-package-root.ts deleted file mode 100644 index 615b85dd1..000000000 --- a/src/main/runtime-package-root.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function runtimePackageRoot(appPath: string, isPackaged: boolean): string { - // External Node processes and OS executable lookup require physical paths. - return isPackaged && appPath.endsWith('.asar') ? `${appPath}.unpacked` : appPath -} diff --git a/src/main/state/host-plugin-sources.ts b/src/main/state/host-plugin-sources.ts index ebef7adc3..d7139c216 100644 --- a/src/main/state/host-plugin-sources.ts +++ b/src/main/state/host-plugin-sources.ts @@ -83,8 +83,8 @@ export async function prepareHostPluginSourcesPatch( ) const names = hostPluginNames(source) if (names.length === 0 && source === original) return desktopPatchPath - // Packaged patches live in resources, while dependencies live under - // app.asar.unpacked. Use the same installation anchor as Harness itself. + // Packaged patches live in resources, while dependencies live in app.asar. + // Use the same installation anchor as Harness itself. const resolveHost = createRequire(hostModuleAnchor).resolve let text = source for (const { name, start, end } of names.reverse()) { diff --git a/test/host-plugin-sources.test.ts b/test/host-plugin-sources.test.ts index cb662ca2d..17eac58ea 100644 --- a/test/host-plugin-sources.test.ts +++ b/test/host-plugin-sources.test.ts @@ -46,11 +46,11 @@ describe('Desktop host plugin sources', () => { expect(await readFile(patchPath, 'utf8')).toBe(source) }) - it('loads host plugins from unpacked resources outside the installation cwd and preserves missing-package causes', async () => { + it('loads host plugins from the packaged app root outside the installation cwd and preserves missing-package causes', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-installed-host-sources-')) directories.push(root) const resources = join(root, 'installation', 'resources') - const runtime = join(resources, 'app.asar.unpacked') + const runtime = join(resources, 'app.asar') const home = join(root, 'profile') const launchRoot = join(root, 'launch-root') const anchor = join(runtime, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js') diff --git a/test/node-pty-unpacked-helper.test.ts b/test/node-pty-asar-layout.test.ts similarity index 60% rename from test/node-pty-unpacked-helper.test.ts rename to test/node-pty-asar-layout.test.ts index 5da66f8c4..ff4ade6c1 100644 --- a/test/node-pty-unpacked-helper.test.ts +++ b/test/node-pty-asar-layout.test.ts @@ -19,14 +19,18 @@ afterEach(async () => { }) // Only macOS launches the shell through node-pty's spawn-helper binary. -describe.runIf(process.platform === 'darwin')('node-pty loaded from app.asar.unpacked', () => { - it('starts a terminal instead of resolving spawn-helper under app.asar.unpacked.unpacked', async () => { +describe.runIf(process.platform === 'darwin')('node-pty in the packaged asar layout', () => { + it('starts a terminal when loaded from app.asar with its native files unpacked', async () => { + // Packaged Harness loads node-pty through app.asar while asarUnpack keeps + // spawn-helper and pty.node in app.asar.unpacked. Upstream node-pty maps + // the one to the other, so this layout needs no node-pty patch. const root = await mkdtemp(join(tmpdir(), 'dsh-node-pty-')) roots.push(root) - // Packaged Harness resolves dependencies from this physical directory. - const unpacked = join(root, 'DSH Desktop.app', 'Contents', 'Resources', 'app.asar.unpacked', 'node_modules', 'node-pty') - await cp(nodePtyRoot, unpacked, { recursive: true }) - const pty = require(unpacked) as NodePty + const resources = join(root, 'DSH Desktop.app', 'Contents', 'Resources') + const packaged = join(resources, 'app.asar', 'node_modules', 'node-pty') + await cp(nodePtyRoot, packaged, { recursive: true }) + await cp(join(nodePtyRoot, 'prebuilds'), join(resources, 'app.asar.unpacked', 'node_modules', 'node-pty', 'prebuilds'), { recursive: true }) + const pty = require(packaged) as NodePty const terminal = pty.spawn('/bin/echo', ['dsh-pty-ok'], { cwd: root, env: process.env }) let output = '' diff --git a/test/office-engine-resolution.test.ts b/test/office-engine-resolution.test.ts new file mode 100644 index 000000000..c5128dd0e --- /dev/null +++ b/test/office-engine-resolution.test.ts @@ -0,0 +1,69 @@ +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { packagedArchiveRoot, registerOfficeEngineResolution } from '../build/office-engine-resolution.mjs' + +const ENGINE = '@deepseek-ai/libreoffice-kit-darwin-arm64' +const hookModule = join(process.cwd(), 'build', 'office-engine-resolution.mjs') +const electron = createRequire(import.meta.url)('electron') as string +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function packagedLayout(): Promise<{ archive: string, entry: string }> { + const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-office-engine-'))) + roots.push(root) + const archive = join(root, 'Resources', 'app.asar') + for (const base of [archive, `${archive}.unpacked`]) { + await mkdir(join(base, 'node_modules', ENGINE), { recursive: true }) + await writeFile(join(base, 'node_modules', ENGINE, 'package.json'), JSON.stringify({ name: ENGINE, version: '0.1.3' })) + } + await mkdir(join(archive, 'node_modules', '@deepseek-ai', 'libreoffice-kit', 'lib'), { recursive: true }) + return { archive, entry: join(archive, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js') } +} + +describe('Office engine resolution', () => { + it('finds the archive root only for an app.asar installation', () => { + expect(packagedArchiveRoot('/App/Contents/Resources/app.asar/node_modules/@deepseek-ai/dsh/lib/bin.js')) + .toBe('/App/Contents/Resources/app.asar') + expect(packagedArchiveRoot('/repo/node_modules/@deepseek-ai/dsh/lib/bin.js')).toBeUndefined() + }) + + // Run on the Electron runtime Harness uses, in Node mode: hooks reach + // require.resolve only on its Node version, and the test runner's own module + // loader does not route through them. + function resolveEngine(archive: string, entry: string | undefined): string { + const script = [ + "import { createRequire } from 'node:module'", + `import { registerOfficeEngineResolution } from ${JSON.stringify(pathToFileURL(hookModule).href)}`, + entry === undefined ? '' : `if (!registerOfficeEngineResolution(${JSON.stringify(entry)})) throw new Error('no hook')`, + `const kit = createRequire(${JSON.stringify(join(archive, 'node_modules', '@deepseek-ai', 'libreoffice-kit', 'lib', 'cli.js'))})`, + `process.stdout.write(kit.resolve(${JSON.stringify(`${ENGINE}/package.json`)}))` + ].join('\n') + const result = spawnSync(electron, ['--input-type=module', '-e', script], { + encoding: 'utf8', + env: { PATH: process.env.PATH, ELECTRON_RUN_AS_NODE: '1' } + }) + if (result.status !== 0) throw new Error(result.stderr) + return result.stdout + } + + it('leaves the engine inside the archive without the hook', async () => { + const { archive } = await packagedLayout() + expect(resolveEngine(archive, undefined)).toBe(join(archive, 'node_modules', ENGINE, 'package.json')) + }) + + it('resolves the engine package to app.asar.unpacked so the OS can spawn it', async () => { + const { archive, entry } = await packagedLayout() + expect(resolveEngine(archive, entry)).toBe(join(`${archive}.unpacked`, 'node_modules', ENGINE, 'package.json')) + }) + + it('installs nothing outside an app.asar installation', () => { + expect(registerOfficeEngineResolution('/repo/node_modules/@deepseek-ai/dsh/lib/bin.js')).toBeUndefined() + }) +}) diff --git a/test/patch-runtime-compatibility.test.mjs b/test/patch-runtime-compatibility.test.mjs index 2b5717b92..76b5c0bb3 100644 --- a/test/patch-runtime-compatibility.test.mjs +++ b/test/patch-runtime-compatibility.test.mjs @@ -16,7 +16,7 @@ it('all patched JavaScript has no unresolved local identifiers', () => { const failures = program.getSemanticDiagnostics().filter(diagnostic => { if (![2304, 2552, 18004].includes(diagnostic.code)) return false const name = diagnostic.file.text.slice(diagnostic.start, diagnostic.start + diagnostic.length) - return !['global', 'setImmediate', 'clearImmediate', '__dirname'].includes(name) + return !['global', 'setImmediate'].includes(name) }).map(diagnostic => `${diagnostic.file.fileName}:${diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + 1}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}`) expect(failures).toEqual([]) }, 30000) diff --git a/test/ppt-source-build-contract.test.ts b/test/ppt-source-build-contract.test.ts index b2f9e8175..b4946c755 100644 --- a/test/ppt-source-build-contract.test.ts +++ b/test/ppt-source-build-contract.test.ts @@ -42,7 +42,7 @@ describe('PPT source build contract', () => { it('packages the current staged directories instead of node_modules links', async () => { const manifest = JSON.parse(await readFile(path.join(projectRoot, 'package.json'), 'utf8')) const files = manifest.build.files - expect(manifest.build.afterPack).toBe('scripts/verify-packaged-ppt-runtime.cjs') + expect(manifest.build.afterPack).toBe('scripts/after-pack.cjs') expect(files).toContain('!node_modules/dsh-ppt{,/**}') expect(files).toContain('!node_modules/dsh-ppt-composer{,/**}') expect(files).toContainEqual(expect.objectContaining({ diff --git a/test/release.test.ts b/test/release.test.ts index dac54067c..c3c2e21f5 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -177,7 +177,14 @@ describe('GitHub release contract', () => { expect(packageJson.build.artifactName).toBe('dsh-desktop-${os}-${arch}.${ext}') expect(packageJson.build.asar).toBe(true) - expect(packageJson.build.asarUnpack).toContain('node_modules/**/*') + // JavaScript stays in app.asar; only files the OS loads or executes unpack. + expect(packageJson.build.asarUnpack).not.toContain('node_modules/**/*') + expect(packageJson.build.asarUnpack).toEqual(expect.arrayContaining([ + '**/*.{node,dylib,dll,so,exe}', + '**/spawn-helper', + '**/@vscode/ripgrep-*/bin/rg', + 'node_modules/@deepseek-ai/libreoffice-kit-*/**/*' + ])) expect(packageJson.build.extraResources).toContainEqual({ from: 'build/app-icon.png', to: 'icon.png' @@ -194,6 +201,11 @@ describe('GitHub release contract', () => { to: 'host-module-fallback.mjs' }) expect(harnessNodeEntry).toContain("from './host-module-fallback.mjs'") + expect(packageJson.build.extraResources).toContainEqual({ + from: 'build/office-engine-resolution.mjs', + to: 'office-engine-resolution.mjs' + }) + expect(harnessNodeEntry).toContain("from './office-engine-resolution.mjs'") expect(windowsHiddenConsole).toContain('export function createHiddenConsole') expect(packageJson.build.extraResources).toContainEqual({ from: 'build/windows-child-process-hide.mjs', diff --git a/test/runtime-package-root.test.ts b/test/runtime-package-root.test.ts deleted file mode 100644 index ad227df9d..000000000 --- a/test/runtime-package-root.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { runtimePackageRoot } from '../src/main/runtime-package-root' - -describe('packaged runtime package root', () => { - it('resolves external Node dependencies to physical unpacked files', () => { - expect(runtimePackageRoot('/Applications/DSH Desktop.app/Contents/Resources/app.asar', true)) - .toBe('/Applications/DSH Desktop.app/Contents/Resources/app.asar.unpacked') - expect(runtimePackageRoot('C:\\Program Files\\DSH Desktop\\resources\\app.asar', true)) - .toBe('C:\\Program Files\\DSH Desktop\\resources\\app.asar.unpacked') - }) - - it('keeps development and legacy unarchived paths intact', () => { - expect(runtimePackageRoot('/repo/dsh-desktop', false)).toBe('/repo/dsh-desktop') - expect(runtimePackageRoot('/app/resources/app', true)).toBe('/app/resources/app') - }) -}) diff --git a/test/verify-asar-unpack.test.ts b/test/verify-asar-unpack.test.ts new file mode 100644 index 000000000..bd640d183 --- /dev/null +++ b/test/verify-asar-unpack.test.ts @@ -0,0 +1,46 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const asar = require('@electron/asar') as { + createPackageWithOptions(src: string, dest: string, options: { unpack?: string }): Promise +} +const { verifyAsarUnpack } = require('../scripts/verify-asar-unpack.cjs') as { + verifyAsarUnpack(resourcesDir: string): void +} + +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +/** A 64-bit Mach-O header followed by padding. */ +const MACHO = Buffer.concat([Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), Buffer.alloc(60)]) + +async function packagedApp(unpack?: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-asar-unpack-')) + roots.push(root) + const app = join(root, 'app') + await mkdir(join(app, 'node_modules', 'tool', 'bin'), { recursive: true }) + await writeFile(join(app, 'node_modules', 'tool', 'index.js'), 'module.exports = 1\n') + await writeFile(join(app, 'node_modules', 'tool', 'bin', 'tool'), MACHO) + const resources = join(root, 'Resources') + await mkdir(resources) + await asar.createPackageWithOptions(app, join(resources, 'app.asar'), unpack === undefined ? {} : { unpack }) + return resources +} + +describe('verifyAsarUnpack', () => { + it('rejects a native binary packed inside app.asar', async () => { + const resources = await packagedApp() + expect(() => verifyAsarUnpack(resources)).toThrow(/node_modules[\\/]tool[\\/]bin[\\/]tool \(Mach-O\)/u) + }) + + it('accepts the binary once asarUnpack places it beside the archive', async () => { + const resources = await packagedApp('**/bin/tool') + expect(() => verifyAsarUnpack(resources)).not.toThrow() + }) +}) From 8be0cf92778c4d30b226644fa58530c2d0df692b Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Fri, 2 Oct 2026 00:53:43 -0700 Subject: [PATCH 2/2] fix(safe-mode): make the sidebar Exit Safe Mode button act on blocking findings With blocking compatibility findings the sidebar button reopened the Safe Mode manager and returned. When the manager was already open the click had no visible effect, while the manager's own restart button asked for confirmation and exited. Ask the same question as the manager's restart button (shared safeModeExitConfirmation / safeModeBlockingGroupCount): "Exit anyway" leaves Safe Mode, "Manage plugins" opens the manager. When the manager is open and waiting, the exit is handed to its restart action so relaunch, the unresolved-findings note and a fall-back into Safe Mode are handled in one place. Co-Authored-By: Claude Opus 5.5 --- src/main/index.ts | 38 ++++++++++++++++++++++++++++++++++---- src/main/safe-mode.ts | 34 +++++++++++++++++++++++----------- test/safe-mode.test.ts | 29 ++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 8b7af0d3a..a33a078e7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -168,7 +168,12 @@ import { shouldOfferWebHomeImport, writeSkipDecision } from './state/web-home-import' -import { buildSafeModeViewModel, shouldStartInSafeMode } from './safe-mode' +import { + buildSafeModeViewModel, + safeModeBlockingGroupCount, + safeModeExitConfirmation, + shouldStartInSafeMode +} from './safe-mode' import { checkupAllProfilePlugins, evaluatePluginMarketCompatibility, @@ -3511,9 +3516,34 @@ async function bootstrap(): Promise { dshHome, join(bundledRuntimeRoot(), 'node_modules') ) - if (compatibility.issues.some((issue) => issue.severity === 'blocking')) { - void showSafeModeManager().catch(showUnexpectedError) - return { ok: false, blocked: true } + const locale = harnessLocale() + const confirmation = safeModeExitConfirmation(safeModeBlockingGroupCount(compatibility.issues), locale) + if (confirmation !== undefined) { + // Ask the same question as the manager's restart button. Reopening an + // already-open manager instead left this click with no visible effect. + const owner = BrowserWindow.fromWebContents(event.sender) + const options: MessageBoxOptions = { + type: 'warning', + message: confirmation, + buttons: locale === 'zh' ? ['仍然退出', '管理插件'] : ['Exit anyway', 'Manage plugins'], + defaultId: 1, + cancelId: 1, + noLink: true + } + const { response } = owner && !owner.isDestroyed() + ? await dialog.showMessageBox(owner, options) + : await dialog.showMessageBox(options) + if (response !== 0) { + if (!safeModeManagerVisible) void showSafeModeManager().catch(showUnexpectedError) + return { ok: false, blocked: true } + } + } + if (safeModeManagerVisible && safeModeActionResolver !== undefined) { + // The open manager owns leaving Safe Mode: its restart action relaunches, + // records unresolved findings, and keeps the manager if startup falls + // back into Safe Mode. + resolveSafeModeAction({ type: 'restart' }) + return { ok: true } } resolveSafeModeAction({ type: 'agent' }) await launchHarness() diff --git a/src/main/safe-mode.ts b/src/main/safe-mode.ts index 27bd3414d..bceb3898a 100644 --- a/src/main/safe-mode.ts +++ b/src/main/safe-mode.ts @@ -93,6 +93,26 @@ export interface SafeModeViewModel { enableBusyLabel: string } +/** Distinct blocking findings, counted by the group a user resolves them in. */ +export function safeModeBlockingGroupCount(issues: readonly ProfileCompatibilityIssue[]): number { + return new Set( + issues + .filter((issue) => issue.severity === 'blocking') + .map((issue) => issue.groupId ?? `${issue.resolution}:${issue.target}`) + ).size +} + +/** + * The question asked before leaving Safe Mode with blocking findings left; + * shared by the manager's restart button and the sidebar's exit button. + */ +export function safeModeExitConfirmation(blockingGroups: number, locale: SafeModeLocale): string | undefined { + if (blockingGroups <= 0) return undefined + return locale === 'zh' + ? `仍有 ${blockingGroups} 组阻断问题。退出后会重新启用第三方插件,可能再次启动失败。仍然退出安全模式吗?` + : `${blockingGroups} blocking group${blockingGroups === 1 ? '' : 's'} remain. Third-party plugins will be enabled again and startup may fail. Exit Safe Mode anyway?` +} + export function shouldStartInSafeMode(argv: readonly string[]): boolean { return argv.includes('--safe-mode') } @@ -282,11 +302,7 @@ export function buildSafeModeViewModel(options: { issues: grouped } }) - const blockingGroups = new Set( - issues - .filter((issue) => issue.severity === 'blocking') - .map((issue) => issue.groupId ?? `${issue.resolution}:${issue.target}`) - ).size + const blockingGroups = safeModeBlockingGroupCount(issues) const backupItems = (options.backups ?? []).map((backup): SafeModeBackupViewModel => { const zh = options.locale === 'zh' const cleanupReady = backup.bootVerifiedAt !== undefined && backup.restoreStartedAt === undefined @@ -374,9 +390,7 @@ export function buildSafeModeViewModel(options: { agentBusyLabel: '正在关闭…', restartLabel: '退出安全模式并重启', restartBusyLabel: '正在重启…', - restartConfirm: blockingGroups > 0 - ? `仍有 ${blockingGroups} 组阻断问题。退出后会重新启用第三方插件,可能再次启动失败。仍然退出安全模式吗?` - : undefined, + restartConfirm: safeModeExitConfirmation(blockingGroups, 'zh'), quitLabel: '退出 DSH Desktop', notice: options.notice, noticeSummary: summarizeLongNotice(options.notice, 'zh'), @@ -418,9 +432,7 @@ export function buildSafeModeViewModel(options: { agentBusyLabel: 'Closing…', restartLabel: 'Exit Safe Mode and restart', restartBusyLabel: 'Restarting…', - restartConfirm: blockingGroups > 0 - ? `${blockingGroups} blocking group${blockingGroups === 1 ? '' : 's'} remain. Third-party plugins will be enabled again and startup may fail. Exit Safe Mode anyway?` - : undefined, + restartConfirm: safeModeExitConfirmation(blockingGroups, 'en'), quitLabel: 'Quit DSH Desktop', notice: options.notice, noticeSummary: summarizeLongNotice(options.notice, 'en'), diff --git a/test/safe-mode.test.ts b/test/safe-mode.test.ts index 71d56810c..47a14a287 100644 --- a/test/safe-mode.test.ts +++ b/test/safe-mode.test.ts @@ -1,7 +1,13 @@ import { readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { buildSafeModeViewModel, shouldStartInSafeMode } from '../src/main/safe-mode' +import { + buildSafeModeViewModel, + safeModeBlockingGroupCount, + safeModeExitConfirmation, + shouldStartInSafeMode +} from '../src/main/safe-mode' +import type { ProfileCompatibilityIssue } from '../src/main/state/profile-compatibility' import { ensureSafeModeProfile, SAFE_MODE_BUNDLES, @@ -139,6 +145,27 @@ describe('Safe Mode', () => { expect(model.restartConfirm).toContain('仍有 1 组阻断问题') }) + it('asks the same exit question wherever Safe Mode can be left', () => { + // The sidebar exit and the manager's restart button share this text; the + // sidebar used to reopen an already-open manager and do nothing visible. + const issue = (target: string, severity: 'blocking' | 'warning', groupId?: string): ProfileCompatibilityIssue => ({ + id: `${target}-${severity}`, + kind: 'core-version-mismatch', + severity, + packageName: target, + source: target, + detail: target, + resolution: 'disable-plugin', + target, + ...(groupId === undefined ? {} : { groupId }) + }) + const issues = [issue('a', 'blocking', 'plugin:a'), issue('a2', 'blocking', 'plugin:a'), issue('b', 'blocking'), issue('c', 'warning')] + expect(safeModeBlockingGroupCount(issues)).toBe(2) + expect(safeModeExitConfirmation(2, 'en')).toBe('2 blocking groups remain. Third-party plugins will be enabled again and startup may fail. Exit Safe Mode anyway?') + expect(safeModeExitConfirmation(1, 'zh')).toContain('仍有 1 组阻断问题') + expect(safeModeExitConfirmation(0, 'en')).toBeUndefined() + }) + it('keeps non-plugin compatibility repairs in the separate repair area', () => { const model = buildSafeModeViewModel({ locale: 'zh',