Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 4 additions & 0 deletions build/harness-node-entry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions build/office-engine-resolution.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export function packagedArchiveRoot(dshEntryPath: string): string | undefined
export function registerOfficeEngineResolution(dshEntryPath: string): { deregister(): void } | undefined
50 changes: 50 additions & 0 deletions build/office-engine-resolution.mjs
Original file line number Diff line number Diff line change
@@ -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`
* (`<root>/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 }
}
})
}
4 changes: 2 additions & 2 deletions docs/release-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ 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

Windows packaging and signing run as separate jobs. The GitHub-hosted Windows runner builds an unsigned NSIS installer and uploads a short-lived workflow artifact. A local macOS ARM64 runner downloads it, scans every PE by content, preserves existing vendor signatures, and signs unsigned PEs with Jsign and the SafeNet UKey. It signs the NSIS extraction helper and generated uninstaller during repackaging, then signs the final installer, regenerates the blockmap and `latest.yml`, and uploads the signed release set. Any missing archive, invalid PE, signing failure or repackaging failure stops the run. A second Windows runner installs the final signed artifact into isolated directories, verifies every PE signature and Harness startup, repeats the same-path installation, and checks that Profile data survives. GitHub publication requires both signing and that installed-artifact smoke to pass.

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:

Expand Down
13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -485,6 +494,6 @@
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"afterPack": "scripts/verify-packaged-ppt-runtime.cjs"
"afterPack": "scripts/after-pack.cjs"
}
}
16 changes: 0 additions & 16 deletions patches/node-pty+1.2.0-beta.15.patch

This file was deleted.

13 changes: 13 additions & 0 deletions scripts/after-pack.cjs
Original file line number Diff line number Diff line change
@@ -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)
}
41 changes: 41 additions & 0 deletions scripts/verify-asar-unpack.cjs
Original file line number Diff line number Diff line change
@@ -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 }
14 changes: 7 additions & 7 deletions scripts/verify-packaged-ppt-runtime.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 41 additions & 6 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -541,8 +545,14 @@ async function syncNativeTheme(window: BrowserWindow): Promise<void> {
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 {
Expand Down Expand Up @@ -3506,9 +3516,34 @@ async function bootstrap(): Promise<void> {
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()
Expand Down
Loading