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..a33a078e7 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' @@ -169,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, @@ -541,8 +545,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 { @@ -3506,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/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/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/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/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', 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() + }) +})