fix(desktop): preserve unpacked PE files and target runtime descriptors

This commit is contained in:
winewill committed 2026-09-17 21:31:34 +08:00
1 parent 702f9e4ff1
commit aa181c66e5
11 files changed
+342 -19

No files matched your search

+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: 26b915e80985d9642cb9536d583314b967f087e2
README.zh.md: 121b2339d1f7936a9e434c7d5a57f014624939fb
README.md: 3bd565cb0d7ad8670f15e833c368c400d632ffad
README.zh.md: 9fd943237a41b4ab6846f7bc33a302d4326ca7b2
+2 -2
View File
@@ -146,7 +146,7 @@ Desktop packs workspace packages locally and installs external dependencies thro
The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree.
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification.
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`.
Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
@@ -251,7 +251,7 @@ pnpm run prepare:desktop
This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content.
Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload.
+2 -2
View File
@@ -147,7 +147,7 @@ Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安
[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。
Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
@@ -252,7 +252,7 @@ pnpm run prepare:desktop
这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。
每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。
+5 -1
View File
@@ -1,3 +1,5 @@
import type { AfterPackContext, BeforePackContext } from 'app-builder-lib'
/** Electron-builder fields asserted by the Desktop release tests. */
export interface DesktopElectronBuilderConfig {
readonly appId: string
@@ -45,7 +47,9 @@ export interface DesktopElectronBuilderConfig {
readonly installerLanguages: readonly ['en_US', 'zh_CN']
}
readonly beforeBuild: () => Promise<boolean>
readonly beforePack: (context: { readonly appOutDir: string }) => Promise<void>
readonly beforePack: (context: BeforePackContext) => Promise<void>
readonly afterPack: (context: AfterPackContext) => Promise<void>
readonly afterSign: (context: AfterPackContext) => Promise<void>
readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise<void> | undefined
readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null
}
@@ -1,6 +1,6 @@
import { X509Certificate } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
@@ -21,7 +21,8 @@ import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environmen
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs'
import { preserveWindowsRuntimeSignature, signWindowsCode, windowsRuntimeCode } from './windows-runtime-signature.mjs'
import { preserveWindowsRuntimeSignature, signWindowsCode } from './windows-runtime-signature.mjs'
import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from './windows-asar-unpack.mjs'
import { recordPackagingEvent } from './packaging-run.mjs'
import {
resolveMacOSAppUpdateFeed,
@@ -61,6 +62,7 @@ export function createElectronBuilderConfig(
const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
let primaryRuntimeDestination
let dshDestination
let windowsCode = []
const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep/bin/rg']
const windowsSigner = packagesWindows && !unsigned
? createWindowsTokenSigner({
@@ -137,12 +139,10 @@ export function createElectronBuilderConfig(
writeUpdateInfo: false,
},
beforePack: async context => {
if (packagesWindows) windowsCode = await prepareWindowsAsarUnpack(context, buildPaths.dsh)
if (windowsSigner !== undefined) {
primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime')
dshDestination = join(context.appOutDir, 'resources', 'app.asar.unpacked', 'dsh')
for (const path of await windowsRuntimeCode(buildPaths.dsh)) {
unpack.push(`dsh/${relative(buildPaths.dsh, path).split(sep).join('/')}`)
}
}
if (policy === undefined) return
const { resolveDesktopPolicyConfig } = await import('../lib/types/mandatory-update-policy.js')
@@ -157,6 +157,8 @@ export function createElectronBuilderConfig(
}
await verifyDesktopRuntime(buildPaths.dsh,
context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
// Unsigned Windows builds skip electron-builder's afterSign hook.
if (packagesWindows && unsigned) await verifyWindowsAsarUnpack(buildPaths.dsh, resourcesDir, windowsCode)
},
afterSign: async context => {
if (windowsSigner !== undefined) {
@@ -165,6 +167,7 @@ export function createElectronBuilderConfig(
sign: windowsSigner,
record: event => recordPackagingEvent(env.DSH_DESKTOP_PACKAGING_RUN_DIR, event),
})
await verifyWindowsAsarUnpack(buildPaths.dsh, context.packager.getResourcesDir(context.appOutDir), windowsCode)
}
if (context.electronPlatformName !== 'darwin') return
const appPath = join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
+2 -2
View File
@@ -151,9 +151,9 @@ async function main(): Promise<void> {
await prepareRuntimeManifests(DSH_OUTPUT_ROOT)
smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))
writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target)
await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
if (!process.argv.includes('--defer-runtime-smoke')) {
await smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT)
await smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor)
await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
}
} catch (error) {
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { desktopNodeEnvironment } from '../src/node-environment.ts'
import { readDesktopRuntime, verifyDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
import { scrubWindowsSigningEnvironment } from './windows-sign.mjs'
import { smokeDesktopRuntime } from './smoke-runtime.ts'
@@ -14,23 +14,22 @@ import { smokeDesktopRuntime } from './smoke-runtime.ts'
* @param root Prepared or archived dsh directory.
* @param node Target Electron executable.
* @param resourcesRuntime External runtime directory beside the archive.
* @param descriptor Verified prepared descriptor for an ASAR tree inaccessible to ordinary Node.
* @param descriptor Runtime descriptor already verified against the selected target, which may differ from the build host.
* @returns Resolves after payload checks, Host readiness and teardown.
*/
export async function smokePreparedRuntime(
root: string, node: string, resourcesRuntime: string, descriptor?: DesktopRuntimeDescriptor,
root: string, node: string, resourcesRuntime: string, descriptor: DesktopRuntimeDescriptor,
): Promise<void> {
const cache = await mkdtemp(join(tmpdir(), 'desktop-native-smoke-'))
const environment = { ...scrubWindowsSigningEnvironment(process.env), NODE_OPTIONS: '',
NARB_NATIVE_CACHE_DIR: cache, NARB_DISABLE_NATIVE_CACHE: '0' }
try {
const runtime = descriptor ?? await verifyDesktopRuntime(root, readDesktopRuntime(root).release.version)
const { stdout } = await promisify(execFile)(node, [
'--expose-internals', resolve(import.meta.dirname, '../tests/fixtures/runtime-payload-smoke.mjs'), root, resourcesRuntime,
], { timeout: 120_000, windowsHide: true,
env: desktopNodeEnvironment(node, join(resourcesRuntime, 'bin'), environment) })
process.stdout.write(stdout)
await smokeDesktopRuntime(root, node, runtime, environment, resourcesRuntime)
await smokeDesktopRuntime(root, node, descriptor, environment, resourcesRuntime)
} finally {
await rm(cache, { recursive: true, force: true })
}
@@ -0,0 +1,18 @@
import type { BeforePackContext } from 'app-builder-lib'
/**
* Install source-relative PE patterns in the active builder configuration.
* @param context Active builder configuration and cleanup owner.
* @param sourceRoot Verified prepared dsh directory; external sources receive a build-owned staging copy.
* @returns PE paths relative to the original prepared directory.
*/
export function prepareWindowsAsarUnpack(context: BeforePackContext, sourceRoot: string): Promise<string[]>
/**
* Reject inline, absent, linked, or changed PE files in the assembled application.
* @param sourceRoot Original signed and sealed dsh directory.
* @param resourcesDir Assembled application resources directory.
* @param files PE paths returned by prepareWindowsAsarUnpack.
* @returns Resolves after every PE has an unpacked ASAR entry and identical bytes.
*/
export function verifyWindowsAsarUnpack(sourceRoot: string, resourcesDir: string, files: string[]): Promise<void>
@@ -0,0 +1,70 @@
/** Keep prepared Windows PE files outside ASAR without changing their sealed bytes. */
import { cp, lstat, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
import { readAsar } from 'app-builder-lib/out/asar/asar.js'
import { windowsRuntimeCode } from './windows-runtime-signature.mjs'
function inside(root, file) {
const suffix = relative(root, file)
return suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix)
}
function unpackPattern(path) {
// Builder removes backslash escapes; brace expansion also ignores character classes.
// A brace matches one character, so similarly named neighbors may also be unpacked.
return path.split(sep).join('/').replace(/[\[\]{}()*?+@#]/gu,
character => character === '{' || character === '}' ? '?' : `[${character}]`).replace(/^!/u, '@(!)')
}
/**
* Install source-relative PE patterns in the configuration consumed by electron-builder.
* @param {import('app-builder-lib').BeforePackContext} context Active builder configuration and cleanup owner.
* @param {string} sourceRoot Verified prepared dsh directory; its files remain unchanged.
* @returns {Promise<string[]>} PE paths relative to the original prepared directory.
*/
export async function prepareWindowsAsarUnpack(context, sourceRoot) {
const files = (await windowsRuntimeCode(sourceRoot)).map(file => relative(sourceRoot, file))
const { config, info } = context.packager
const { appDir } = info
let copyRoot = sourceRoot
if (!inside(appDir, sourceRoot)) {
// Builder's source matcher slices the appDir prefix even for external FileSets.
const parent = join(appDir, '.desktop-build')
await mkdir(parent, { recursive: true })
const stage = await mkdtemp(join(parent, 'asar-source-'))
copyRoot = join(stage, 'dsh')
info.disposeOnBuildFinish(() => rm(stage, { recursive: true, force: true }))
await cp(sourceRoot, copyRoot, { recursive: true, force: false, errorOnExist: true })
config.files = config.files.map(file => {
if (typeof file === 'string' || file.from === undefined) return file
const from = resolve(appDir, file.from)
return inside(sourceRoot, from) ? { ...file, from: join(copyRoot, relative(sourceRoot, from)) } : file
})
}
const existing = config.asarUnpack ?? []
config.asarUnpack = [...(typeof existing === 'string' ? [existing] : existing),
...files.map(file => unpackPattern(relative(appDir, join(copyRoot, file))))]
return files
}
/**
* Reject inline, absent, linked, or changed PE files in the assembled application.
* @param {string} sourceRoot Original signed and sealed dsh directory.
* @param {string} resourcesDir Assembled application resources directory.
* @param {string[]} files PE paths returned by prepareWindowsAsarUnpack.
* @returns {Promise<void>} Resolves after every PE has an unpacked ASAR entry and identical bytes.
*/
export async function verifyWindowsAsarUnpack(sourceRoot, resourcesDir, files) {
const archive = await readAsar(join(resourcesDir, 'app.asar'))
for (const file of files) {
const entry = archive.getFile(join('dsh', file), false)
if (entry.unpacked !== true || entry.link !== undefined) {
throw new Error(`Windows ASAR: PE must be unpacked: ${file}`)
}
const copied = join(resourcesDir, 'app.asar.unpacked', 'dsh', file)
const stat = await lstat(copied)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Windows ASAR: expected a real PE file: ${file}`)
const [prepared, packaged] = await Promise.all([readFile(join(sourceRoot, file)), readFile(copied)])
if (!prepared.equals(packaged)) throw new Error(`Windows ASAR: PE bytes changed: ${file}`)
}
}
@@ -0,0 +1,44 @@
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { smokePreparedRuntime } from '../scripts/smoke-prepared-runtime.ts'
import { smokeDesktopRuntime } from '../scripts/smoke-runtime.ts'
import { verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts'
import { runtimeFixture } from './runtime-fixture.ts'
const { payload } = vi.hoisted(() => ({ payload: vi.fn(async (..._args: unknown[]) => ({ stdout: '' })) }))
vi.mock('node:child_process', async (importOriginal) => {
const { promisify } = await import('node:util')
return { ...await importOriginal<typeof import('node:child_process')>(),
execFile: Object.assign(vi.fn(), { [promisify.custom]: payload }) }
})
vi.mock('../scripts/smoke-runtime.ts', () => ({ smokeDesktopRuntime: vi.fn(async () => {}) }))
const roots: string[] = []
const hostArch = Object.getOwnPropertyDescriptor(process, 'arch')!
afterEach(() => {
Object.defineProperty(process, 'arch', hostArch)
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
vi.clearAllMocks()
})
it('smokes an x64 target verified on an arm64 build host without revalidating against the host', async () => {
const root = mkdtempSync(join(tmpdir(), 'prepared-runtime-target-'))
roots.push(root)
const fixture = runtimeFixture(root)
const target = { platform: 'darwin' as const, arch: 'x64' }
writeDesktopRuntime(root, fixture.release, fixture.sharedPackages.map(entry => entry.name), target)
Object.defineProperty(process, 'arch', { ...hostArch, value: 'arm64' })
await expect(verifyDesktopRuntime(root, fixture.release.version, { ...target, arch: process.arch }))
.rejects.toThrow(/incompatible/u)
const descriptor = await verifyDesktopRuntime(root, fixture.release.version, target)
const electron = join(root, 'target-electron')
const resources = join(root, 'runtime')
await smokePreparedRuntime(root, electron, resources, descriptor)
expect(payload.mock.calls[0]![0]).toBe(electron)
expect(payload.mock.calls[0]![1]).toEqual(expect.arrayContaining([root, resources]))
expect(smokeDesktopRuntime).toHaveBeenCalledWith(root, electron, descriptor, expect.any(Object), resources)
const environment = vi.mocked(smokeDesktopRuntime).mock.calls[0]![3]
expect(existsSync(environment.NARB_NATIVE_CACHE_DIR!)).toBe(false)
})
@@ -0,0 +1,185 @@
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import type { Stats } from 'node:fs'
import { Arch, Packager, type BeforePackContext, type Configuration } from 'app-builder-lib'
import { WinPackager } from 'app-builder-lib/out/winPackager.js'
import { getFileMatchers } from 'app-builder-lib/out/fileMatcher.js'
import { readAsar } from 'app-builder-lib/out/asar/asar.js'
import { afterEach, expect, it, vi } from 'vitest'
import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from '../scripts/windows-asar-unpack.mjs'
import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs'
vi.mock('../scripts/windows-sign.mjs', async importOriginal => ({
...await importOriginal<typeof import('../scripts/windows-sign.mjs')>(),
createWindowsTokenSigner: () => async () => {},
installWindowsNsisBootstrapSigner: () => {},
resolveWindowsUpdatePublisher: () => 'Fixture Publisher',
}))
vi.mock('../scripts/windows-runtime-signature.mjs', async importOriginal => ({
...await importOriginal<typeof import('../scripts/windows-runtime-signature.mjs')>(),
signWindowsCode: async () => {},
}))
// Vite's root-relative IDs also resolve mocked build outputs on a clean checkout.
vi.mock('/apps/desktop/lib/types/runtime-tree.js', () => ({ verifyDesktopRuntime: async () => {} }))
vi.mock('/apps/desktop/lib/types/mandatory-update-policy.js', async () => import('../src/mandatory-update-policy.ts'))
vi.mock('node:crypto', async importOriginal => ({
...await importOriginal<typeof import('node:crypto')>(),
X509Certificate: class { fingerprint = 'AA:BB' },
}))
const require = createRequire(import.meta.url)
// The locked builder omits declarations for its AsarPackager implementation.
const { AsarPackager } = require('app-builder-lib/out/asar/asarUtil.js') as {
AsarPackager: new (packager: object, configuration: object) => {
pack(files: { src: string; destination: string; files: string[]; metadata: Map<string, Stats> }[]): Promise<void>
}
}
const roots: string[] = []
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) await cleanup()
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
async function fixture(external: boolean) {
const root = await mkdtemp(join(tmpdir(), 'windows-asar-'))
roots.push(root)
const appDir = join(root, 'app')
const source = external ? join(root, 'private', 'dsh') : join(appDir, '.desktop-build', 'dsh')
const resources = join(root, 'output', 'resources')
await mkdir(resources, { recursive: true })
await mkdir(appDir, { recursive: true })
const pe = Buffer.alloc(128)
pe.writeUInt16LE(0x5a4d, 0)
pe.writeUInt32LE(64, 0x3c)
pe.writeUInt32LE(0x4550, 64)
const names = ['extensionless', 'custom.binary', 'native.node', 'a[0]{x,y}+(z)@!#.binary', '${arch}.binary']
for (const name of names) {
const file = join(source, 'node_modules', 'foo', name)
await mkdir(dirname(file), { recursive: true })
await writeFile(file, pe)
}
await writeFile(join(source, 'node_modules', 'foo', 'companion.json'), '{}')
await writeFile(join(source, 'node_modules', 'foo', '$xarchy.binary'), 'neighbor')
const config = {
files: [{ from: source, to: 'dsh', filter: ['**/*'] },
{ from: join(source, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }],
asarUnpack: ['**/*.node'],
} satisfies Configuration
const info = new Packager({ projectDir: appDir })
Object.defineProperties(info, {
appDir: { value: appDir }, config: { value: config },
metadata: { value: { name: 'fixture', version: '1.0.0', description: 'Fixture' } },
framework: { value: { name: 'electron' } },
})
vi.spyOn(info, 'disposeOnBuildFinish').mockImplementation((cleanup) => { cleanups.push(cleanup) })
const context: BeforePackContext = { packager: new WinPackager(info), arch: Arch.x64,
appOutDir: dirname(resources), outDir: dirname(resources), electronPlatformName: 'win32', targets: [] }
return { root, appDir, source, resources, config, context, names }
}
async function packageFixture(input: Awaited<ReturnType<typeof fixture>>) {
const destination = join(input.resources, 'app')
const matchers = getFileMatchers(input.config, 'asarUnpack', destination, {
defaultSrc: input.appDir, globalOutDir: dirname(input.resources), customBuildOptions: {},
macroExpander: value => value.replaceAll('${arch}', 'x64'),
})!
const mapping = input.config.files[0]!
if (typeof mapping === 'string' || mapping.from === undefined) throw new Error('missing source mapping')
const files: string[] = []
const metadata = new Map<string, Stats>()
async function visit(directory: string): Promise<void> {
for (const name of await readdir(directory)) {
const file = join(directory, name)
const stat = await lstat(file)
if (stat.isDirectory()) await visit(file)
else { files.push(file); metadata.set(file, stat) }
}
}
await visit(mapping.from)
await new AsarPackager({ info: { getWorkspaceRoot: async () => input.root } }, {
defaultDestination: destination, resourcePath: input.resources, options: {}, unpackPattern: matchers[0]!.createFilter(),
}).pack([{ src: mapping.from, destination: join(destination, 'dsh'), files, metadata }])
}
it.each([false, true])('unpacks PE files through builder source patterns (external=%s)', async (external) => {
const input = await fixture(external)
const files = await prepareWindowsAsarUnpack(input.context, input.source)
await packageFixture(input)
await verifyWindowsAsarUnpack(input.source, input.resources, files)
const archive = await readAsar(join(input.resources, 'app.asar'))
expect(archive.getFile(join('dsh', 'node_modules', 'foo', 'companion.json')).unpacked).not.toBe(true)
expect(archive.getFile(join('dsh', 'node_modules', 'foo', '$xarchy.binary')).unpacked).toBe(true)
expect(files).toEqual(input.names.map(name => join('node_modules', 'foo', name)).sort())
const first = input.config.files[0]!
const nested = input.config.files[1]!
if (typeof first === 'string' || typeof nested === 'string') throw new Error('missing source mappings')
expect(first.from === input.source).toBe(!external)
expect(nested.from).toBe(join(first.from, 'node_modules'))
expect(cleanups.length).toBe(external ? 1 : 0)
if (external) {
await cleanups.pop()!()
await expect(lstat(first.from)).rejects.toMatchObject({ code: 'ENOENT' })
expect(await readFile(join(input.source, files[0]!))).toHaveLength(128)
}
})
it('rejects an inline PE even when a neighboring unpacked copy exists', async () => {
const input = await fixture(false)
const files = await prepareWindowsAsarUnpack(input.context, input.source)
input.config.asarUnpack = ['**/*.node']
await packageFixture(input)
const path = join(input.resources, 'app.asar.unpacked', 'dsh', files[0]!)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, await readFile(join(input.source, files[0]!)))
await expect(verifyWindowsAsarUnpack(input.source, input.resources, files)).rejects.toThrow('PE must be unpacked')
})
it.each(['changed', 'missing'] as const)('rejects %s unpacked PE bytes', async (state) => {
const input = await fixture(false)
const files = await prepareWindowsAsarUnpack(input.context, input.source)
await packageFixture(input)
const path = join(input.resources, 'app.asar.unpacked', 'dsh', files[0]!)
if (state === 'changed') await writeFile(path, 'changed')
else await rm(path)
await expect(verifyWindowsAsarUnpack(input.source, input.resources, files)).rejects.toThrow(state === 'changed' ? 'PE bytes changed' : 'ENOENT')
})
it('registers external staging cleanup before a configuration rewrite fails', async () => {
const input = await fixture(true)
const files = input.config.files
// The source still passes PE discovery; a destination mapping setter fails after staging is copied.
Object.defineProperty(input.config, 'files', { get: () => files, set: () => { throw new Error('configuration rejected') } })
await expect(prepareWindowsAsarUnpack(input.context, input.source)).rejects.toThrow('configuration rejected')
const staging = await readdir(join(input.appDir, '.desktop-build'))
expect(staging).toHaveLength(1)
expect(cleanups).toHaveLength(1)
await cleanups.pop()!()
expect(await readdir(join(input.appDir, '.desktop-build'))).toEqual([])
})
it.each([true, false])('validates the real builder hook for unsigned=%s', async (unsigned) => {
const input = await fixture(true)
const certificate = join(input.root, 'certificate.cer')
await writeFile(certificate, 'fixture public certificate')
const config = createElectronBuilderConfig({
DSH_DESKTOP_APP_ID: 'com.example.unpack', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0',
DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com',
}, 'win32', 'x64', input.source)
// Qualification replaces files after creating the base configuration; builder owns a separate config object.
input.config.asarUnpack = [...config.asarUnpack]
await config.beforePack(input.context)
await packageFixture(input)
await config.afterPack(input.context)
if (!unsigned) await config.afterSign(input.context)
const file = join(input.resources, 'app.asar.unpacked', 'dsh', 'node_modules', 'foo', 'custom.binary')
await writeFile(file, 'changed after packaging')
if (unsigned) await expect(config.afterPack(input.context)).rejects.toThrow('PE bytes changed')
else {
await config.afterPack(input.context)
await expect(config.afterSign(input.context)).rejects.toThrow('PE bytes changed')
}
})