Files
deepseek-harness/apps/desktop/scripts/package-target.ts
T
winewill c47a2c16b4 feat(desktop): load platform packaging settings from local dotenv files
Load target-owned Windows and macOS release settings, validate them before
preparation, and provide a configuration-only check command. Share the local
settings with upload, installer tests, and manual macOS signature checks.

Exercise English and Chinese installer fixtures using visible UI labels and
cover registered installation paths with trailing separators.
2026-09-14 12:47:08 +08:00

342 lines
13 KiB
TypeScript

/** Build one release target with matching Electron and dsh architecture. */
import { spawn } from 'node:child_process'
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
import { join, resolve } from 'node:path'
import {
desktopBuildRecordFilename,
resolveDesktopAutoUpdateConfig,
} from './desktop-auto-update-environment.mjs'
import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts'
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
const APP_ROOT = resolve(import.meta.dirname, '..')
const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
const WINDOWS_SIGNING_ENV_PREFIX = 'DSH_DESKTOP_WINDOWS_'
const WINDOWS_SIGNING_ENV_NAMES = [
'DSH_DESKTOP_WINDOWS_CER_FILE',
'DSH_DESKTOP_WINDOWS_KEY_CONTAINER',
'DSH_DESKTOP_WINDOWS_SIGNTOOL',
'DSH_DESKTOP_WINDOWS_TOKEN_PIN',
] as const
const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([
'DOWNLOAD_TEST_COS_SECRET_ID',
'DOWNLOAD_TEST_COS_SECRET_KEY',
'DOWNLOAD_PROD_COS_SECRET_ID',
'DOWNLOAD_PROD_COS_SECRET_KEY',
])
/** Fixed platform and architecture identifiers exposed by package scripts. */
export type DesktopPackageTargetName = 'mac-arm64' | 'mac-x64' | 'win-x64'
/** One supported release target and its electron-builder selectors. */
export interface DesktopPackageTarget {
readonly name: DesktopPackageTargetName
readonly platform: 'darwin' | 'win32'
readonly arch: 'arm64' | 'x64'
readonly builderPlatform: '--mac' | '--win'
readonly builderArch: '--arm64' | '--x64'
}
const TARGETS: Record<DesktopPackageTargetName, DesktopPackageTarget> = {
'mac-arm64': {
name: 'mac-arm64',
platform: 'darwin',
arch: 'arm64',
builderPlatform: '--mac',
builderArch: '--arm64',
},
'mac-x64': {
name: 'mac-x64',
platform: 'darwin',
arch: 'x64',
builderPlatform: '--mac',
builderArch: '--x64',
},
'win-x64': {
name: 'win-x64',
platform: 'win32',
arch: 'x64',
builderPlatform: '--win',
builderArch: '--x64',
},
}
/**
* Remove Windows signing configuration from package preparation subprocesses.
* @param environment - Packaging command environment.
* @returns A copy without Windows signing fields.
*/
export function withoutWindowsSigningEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return Object.fromEntries(Object.entries(environment)
.filter(([name]) => !name.startsWith(WINDOWS_SIGNING_ENV_PREFIX)))
}
/**
* Select signing and NSIS-compatible archive filters for electron-builder.
* @param environment - Target packaging environment.
* @param unsigned - Whether to create a local unsigned Windows artifact.
* @returns Packaging environment without certificate inputs for unsigned builds.
*/
export function desktopElectronBuilderEnvironment(environment: NodeJS.ProcessEnv, unsigned: boolean): NodeJS.ProcessEnv {
const selected: NodeJS.ProcessEnv = { ...environment, DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0' }
// The bundled NSIS decoder cannot extract 7-Zip's automatic ARM64-filtered entries.
if (environment.DSH_DESKTOP_TARGET_PLATFORM === 'win32') selected.ELECTRON_BUILDER_7Z_FILTER = 'BCJ'
if (!unsigned) return selected
return {
...Object.fromEntries(Object.entries(withoutWindowsSigningEnvironment(selected))
.filter(([name]) => !/^(?:WIN_)?CSC_/iu.test(name))),
CSC_IDENTITY_AUTO_DISCOVERY: 'false',
DSH_DESKTOP_UNSIGNED: '1',
}
}
/**
* Remove upload-only COS credentials from every packaging subprocess.
* @param environment - Packaging command environment.
* @returns A copy without Desktop upload credentials.
*/
export function withoutDesktopUploadCredentials(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return Object.fromEntries(Object.entries(environment)
.filter(([name]) => !DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES.has(name)))
}
function isTargetName(value: string): value is DesktopPackageTargetName {
return Object.hasOwn(TARGETS, value)
}
function packageVersion(path: string, label: string): string {
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }
if (typeof manifest.version !== 'string' || manifest.version === '') {
throw new Error(`desktop package: ${label} has no version`)
}
return manifest.version
}
function writeReleaseRecord(
target: DesktopPackageTarget,
environment: NodeJS.ProcessEnv,
artifactsRoot: string,
): void {
const desktopVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')
const dshVersion = packageVersion(join(REPOSITORY_ROOT, 'package.json'), 'dsh package')
if (desktopVersion !== dshVersion) {
throw new Error(`desktop package: desktop version ${desktopVersion} does not match dsh version ${dshVersion}`)
}
const update = resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
const recordPath = join(artifactsRoot, desktopBuildRecordFilename(target.name))
const temporaryPath = `${recordPath}.tmp`
writeFileSync(temporaryPath, `${JSON.stringify({
schemaVersion: 1,
target: target.name,
version: dshVersion,
environment: update.environment,
publicUrl: update.publicUrl,
}, null, 2)}\n`)
renameSync(temporaryPath, recordPath)
}
/**
* Resolve a named release target and reject hosts that cannot execute its packaged runtime.
* @param name - One of the fixed Desktop release target names.
* @param hostPlatform - Build-host Node.js platform.
* @param hostArch - Build-host Node.js architecture.
* @returns The target selectors shared by runtime preparation and electron-builder.
*/
export function resolveDesktopPackageTarget(
name: string,
hostPlatform: NodeJS.Platform = process.platform,
hostArch: string = process.arch,
): DesktopPackageTarget {
if (!isTargetName(name)) {
throw new Error(`desktop package: unsupported target ${JSON.stringify(name)}; expected ${Object.keys(TARGETS).join(', ')}`)
}
const target = TARGETS[name]
if (target.platform === 'win32' && (hostPlatform !== 'win32' || hostArch !== 'x64')) {
throw new Error('desktop package: win-x64 requires a Windows x64 build host')
}
if (target.platform === 'darwin' && hostPlatform !== 'darwin') {
throw new Error(`desktop package: ${name} requires a macOS build host`)
}
if (name === 'mac-arm64' && hostArch !== 'arm64') {
throw new Error('desktop package: mac-arm64 requires an Apple Silicon build host')
}
if (name === 'mac-x64' && hostArch !== 'arm64' && hostArch !== 'x64') {
throw new Error('desktop package: mac-x64 requires an Intel Mac or Apple Silicon with Rosetta')
}
return target
}
interface DesktopPackageInvocation {
readonly target: DesktopPackageTarget
readonly directory: boolean
readonly prepareOnly: boolean
readonly unsigned: boolean
readonly check: boolean
}
function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName {
const name = `${platform === 'darwin' ? 'mac' : platform === 'win32' ? 'win' : platform}-${arch}`
if (!isTargetName(name)) throw new Error(`desktop package: unsupported build host ${platform}-${arch}`)
return name
}
/**
* Parse the fixed-target packaging command line.
* @param argv - Arguments after the script entry point.
* @param hostPlatform - Build-host Node.js platform.
* @param hostArch - Build-host Node.js architecture.
* @returns The validated target and whether to emit an unpacked directory.
*/
export function parseDesktopPackageInvocation(
argv: readonly string[],
hostPlatform: NodeJS.Platform = process.platform,
hostArch: string = process.arch,
): DesktopPackageInvocation {
const { values, positionals } = parseArgs({
args: [...argv],
allowPositionals: true,
options: {
dir: { type: 'boolean', default: false },
'prepare-only': { type: 'boolean', default: false },
unsigned: { type: 'boolean', default: false },
check: { type: 'boolean', default: false },
},
})
if (positionals.length > 1) throw new Error('desktop package: expected at most one target')
const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch)
if (values.unsigned && name !== 'win-x64') throw new Error('desktop package: --unsigned requires win-x64')
if (values.unsigned && values['prepare-only']) throw new Error('desktop package: --unsigned cannot use --prepare-only')
return {
target: resolveDesktopPackageTarget(name, hostPlatform, hostArch),
directory: values.dir,
prepareOnly: values['prepare-only'],
unsigned: values.unsigned,
check: values.check,
}
}
/**
* Build the electron-builder command arguments for one validated target.
* @param target - Supported release target.
* @param directory - Whether to stop at an unpacked application directory.
* @param artifact - Optional single artifact built from an existing signed application.
* @returns Arguments that keep publishing under the separate validated upload command.
*/
export function desktopElectronBuilderArguments(
target: DesktopPackageTarget,
directory: boolean,
artifact?: DesktopPrepackagedArtifact,
): readonly string[] {
return [
'exec',
'electron-builder',
'--config',
'electron-builder.config.mjs',
target.builderPlatform,
...(artifact === undefined ? [] : [artifact.format]),
target.builderArch,
'--publish',
'never',
...(directory ? ['--dir'] : []),
...(artifact === undefined ? [] : [
...(target.platform === 'darwin' ? ['--config.mac.notarize=false'] : []),
'--prepackaged', artifact.appPath,
'--config.directories.output', artifact.output,
]),
]
}
function runPnpm(
args: readonly string[],
env: NodeJS.ProcessEnv = process.env,
cwd: string = APP_ROOT,
): Promise<void> {
const pnpmEntry = process.env.npm_execpath
if (pnpmEntry === undefined || pnpmEntry === '') {
throw new Error('desktop package: invoke this script through a pnpm package command')
}
return new Promise((resolvePromise, reject) => {
const child = spawn(process.execPath, [pnpmEntry, ...args], {
cwd,
env,
stdio: 'inherit',
})
child.once('error', reject)
child.once('close', (code, signal) => {
if (code === 0) resolvePromise()
else reject(new Error(`desktop package: pnpm ${args.join(' ')} exited with ${String(code ?? signal)}`))
})
})
}
async function main(): Promise<void> {
const invocation = parseDesktopPackageInvocation(process.argv.slice(2))
const { target } = invocation
const environment = loadDesktopPackageEnvironment(target.platform)
validateDesktopPackageEnvironment(environment, target, invocation)
if (invocation.check) {
process.stdout.write(`desktop package: ${target.name} local configuration valid; signing and notarization were not attempted\n`)
return
}
const buildPaths = desktopTargetBuildPaths(target.name)
const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
if (!invocation.prepareOnly && !invocation.unsigned) {
rmSync(releaseRecordPath, { force: true })
rmSync(`${releaseRecordPath}.tmp`, { force: true })
}
const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials(environment))
const targetEnv: NodeJS.ProcessEnv = {
...buildEnv,
DSH_DESKTOP_TARGET_PLATFORM: target.platform,
DSH_DESKTOP_TARGET_ARCH: target.arch,
}
const electronBuilderEnv = desktopElectronBuilderEnvironment(targetEnv, invocation.unsigned)
for (const name of WINDOWS_SIGNING_ENV_NAMES) {
if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name]
}
await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
await runPnpm([
'--dir',
'apps/desktop-host',
'pack',
'--pack-destination',
buildPaths.packedDsh,
], buildEnv, REPOSITORY_ROOT)
await runPnpm(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT)
rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
mkdirSync(buildPaths.packedLandlock, { recursive: true })
await runPnpm(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
await runPnpm([
'--dir',
'native/system/packages/entry',
'pack',
'--pack-destination',
buildPaths.packedLandlock,
], buildEnv, REPOSITORY_ROOT)
await runPnpm(['run', 'prepare:runtime'], targetEnv)
await runPnpm(['run', 'prepare:packages'], targetEnv)
await runPnpm(['run', 'prepare:dsh'], targetEnv)
if (invocation.prepareOnly) return
if (target.platform === 'darwin' && !invocation.directory) {
await runPnpm([
...desktopElectronBuilderArguments(target, true),
'--config.mac.notarize=false',
], electronBuilderEnv)
await packageMacOSArtifacts({
arch: target.arch,
version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
artifactsRoot: buildPaths.artifacts,
environment: electronBuilderEnv,
}, artifact => runPnpm(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv))
} else {
await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
}
if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()