package-target.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. /** Build one release target with matching Electron and dsh architecture. */
  2. import { spawn } from 'node:child_process'
  3. import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
  4. import { parseArgs } from 'node:util'
  5. import { join, resolve } from 'node:path'
  6. import {
  7. desktopBuildRecordFilename,
  8. resolveDesktopAutoUpdateConfig,
  9. } from './desktop-auto-update-environment.mjs'
  10. import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
  11. import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts'
  12. import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
  13. import { createPackagingRun } from './packaging-run.mjs'
  14. import { withMacOSSigningKeychain } from './macos-signing-keychain.mjs'
  15. const APP_ROOT = resolve(import.meta.dirname, '..')
  16. const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
  17. const WINDOWS_SIGNING_ENV_PREFIX = 'DSH_DESKTOP_WINDOWS_'
  18. const WINDOWS_SIGNING_ENV_NAMES = [
  19. 'DSH_DESKTOP_WINDOWS_CER_FILE',
  20. 'DSH_DESKTOP_WINDOWS_KEY_CONTAINER',
  21. 'DSH_DESKTOP_WINDOWS_SIGNTOOL',
  22. 'DSH_DESKTOP_WINDOWS_TOKEN_PIN',
  23. ] as const
  24. const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([
  25. 'DOWNLOAD_TEST_COS_SECRET_ID',
  26. 'DOWNLOAD_TEST_COS_SECRET_KEY',
  27. 'DOWNLOAD_PROD_COS_SECRET_ID',
  28. 'DOWNLOAD_PROD_COS_SECRET_KEY',
  29. ])
  30. /** Fixed platform and architecture identifiers exposed by package scripts. */
  31. export type DesktopPackageTargetName = 'mac-arm64' | 'mac-x64' | 'win-x64'
  32. /** One supported release target and its electron-builder selectors. */
  33. export interface DesktopPackageTarget {
  34. readonly name: DesktopPackageTargetName
  35. readonly platform: 'darwin' | 'win32'
  36. readonly arch: 'arm64' | 'x64'
  37. readonly builderPlatform: '--mac' | '--win'
  38. readonly builderArch: '--arm64' | '--x64'
  39. }
  40. const TARGETS: Record<DesktopPackageTargetName, DesktopPackageTarget> = {
  41. 'mac-arm64': {
  42. name: 'mac-arm64',
  43. platform: 'darwin',
  44. arch: 'arm64',
  45. builderPlatform: '--mac',
  46. builderArch: '--arm64',
  47. },
  48. 'mac-x64': {
  49. name: 'mac-x64',
  50. platform: 'darwin',
  51. arch: 'x64',
  52. builderPlatform: '--mac',
  53. builderArch: '--x64',
  54. },
  55. 'win-x64': {
  56. name: 'win-x64',
  57. platform: 'win32',
  58. arch: 'x64',
  59. builderPlatform: '--win',
  60. builderArch: '--x64',
  61. },
  62. }
  63. /**
  64. * Remove Windows signing configuration from package preparation subprocesses.
  65. * @param environment - Packaging command environment.
  66. * @returns A copy without Windows signing fields.
  67. */
  68. export function withoutWindowsSigningEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
  69. return Object.fromEntries(Object.entries(environment)
  70. .filter(([name]) => !name.startsWith(WINDOWS_SIGNING_ENV_PREFIX)))
  71. }
  72. /**
  73. * Select signing and NSIS-compatible archive filters for electron-builder.
  74. * @param environment - Target packaging environment.
  75. * @param unsigned - Whether to create a local unsigned Windows artifact.
  76. * @returns Packaging environment without certificate inputs for unsigned builds.
  77. */
  78. export function desktopElectronBuilderEnvironment(environment: NodeJS.ProcessEnv, unsigned: boolean): NodeJS.ProcessEnv {
  79. const selected: NodeJS.ProcessEnv = { ...environment, DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0' }
  80. // The bundled NSIS decoder cannot extract 7-Zip's automatic ARM64-filtered entries.
  81. if (environment.DSH_DESKTOP_TARGET_PLATFORM === 'win32') selected.ELECTRON_BUILDER_7Z_FILTER = 'BCJ'
  82. if (!unsigned) return selected
  83. return {
  84. ...Object.fromEntries(Object.entries(withoutWindowsSigningEnvironment(selected))
  85. .filter(([name]) => !/^(?:WIN_)?CSC_/iu.test(name))),
  86. CSC_IDENTITY_AUTO_DISCOVERY: 'false',
  87. DSH_DESKTOP_UNSIGNED: '1',
  88. }
  89. }
  90. /**
  91. * Remove upload-only COS credentials from every packaging subprocess.
  92. * @param environment - Packaging command environment.
  93. * @returns A copy without Desktop upload credentials.
  94. */
  95. export function withoutDesktopUploadCredentials(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
  96. return Object.fromEntries(Object.entries(environment)
  97. .filter(([name]) => !DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES.has(name)))
  98. }
  99. function isTargetName(value: string): value is DesktopPackageTargetName {
  100. return Object.hasOwn(TARGETS, value)
  101. }
  102. function packageVersion(path: string, label: string): string {
  103. const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }
  104. if (typeof manifest.version !== 'string' || manifest.version === '') {
  105. throw new Error(`desktop package: ${label} has no version`)
  106. }
  107. return manifest.version
  108. }
  109. function writeReleaseRecord(
  110. target: DesktopPackageTarget,
  111. environment: NodeJS.ProcessEnv,
  112. artifactsRoot: string,
  113. ): void {
  114. const desktopVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')
  115. const dshVersion = packageVersion(join(REPOSITORY_ROOT, 'package.json'), 'dsh package')
  116. if (desktopVersion !== dshVersion) {
  117. throw new Error(`desktop package: desktop version ${desktopVersion} does not match dsh version ${dshVersion}`)
  118. }
  119. const update = resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
  120. const recordPath = join(artifactsRoot, desktopBuildRecordFilename(target.name))
  121. const temporaryPath = `${recordPath}.tmp`
  122. writeFileSync(temporaryPath, `${JSON.stringify({
  123. schemaVersion: 1,
  124. target: target.name,
  125. version: dshVersion,
  126. environment: update.environment,
  127. publicUrl: update.publicUrl,
  128. }, null, 2)}\n`)
  129. renameSync(temporaryPath, recordPath)
  130. }
  131. /**
  132. * Resolve a named release target and reject hosts that cannot execute its packaged runtime.
  133. * @param name - One of the fixed Desktop release target names.
  134. * @param hostPlatform - Build-host Node.js platform.
  135. * @param hostArch - Build-host Node.js architecture.
  136. * @returns The target selectors shared by runtime preparation and electron-builder.
  137. */
  138. export function resolveDesktopPackageTarget(
  139. name: string,
  140. hostPlatform: NodeJS.Platform = process.platform,
  141. hostArch: string = process.arch,
  142. ): DesktopPackageTarget {
  143. if (!isTargetName(name)) {
  144. throw new Error(`desktop package: unsupported target ${JSON.stringify(name)}; expected ${Object.keys(TARGETS).join(', ')}`)
  145. }
  146. const target = TARGETS[name]
  147. if (target.platform === 'win32' && (hostPlatform !== 'win32' || hostArch !== 'x64')) {
  148. throw new Error('desktop package: win-x64 requires a Windows x64 build host')
  149. }
  150. if (target.platform === 'darwin' && hostPlatform !== 'darwin') {
  151. throw new Error(`desktop package: ${name} requires a macOS build host`)
  152. }
  153. if (name === 'mac-arm64' && hostArch !== 'arm64') {
  154. throw new Error('desktop package: mac-arm64 requires an Apple Silicon build host')
  155. }
  156. if (name === 'mac-x64' && hostArch !== 'arm64' && hostArch !== 'x64') {
  157. throw new Error('desktop package: mac-x64 requires an Intel Mac or Apple Silicon with Rosetta')
  158. }
  159. return target
  160. }
  161. interface DesktopPackageInvocation {
  162. readonly target: DesktopPackageTarget
  163. readonly directory: boolean
  164. readonly prepareOnly: boolean
  165. readonly unsigned: boolean
  166. readonly check: boolean
  167. }
  168. function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName {
  169. const name = `${platform === 'darwin' ? 'mac' : platform === 'win32' ? 'win' : platform}-${arch}`
  170. if (!isTargetName(name)) throw new Error(`desktop package: unsupported build host ${platform}-${arch}`)
  171. return name
  172. }
  173. /**
  174. * Parse the fixed-target packaging command line.
  175. * @param argv - Arguments after the script entry point.
  176. * @param hostPlatform - Build-host Node.js platform.
  177. * @param hostArch - Build-host Node.js architecture.
  178. * @returns The validated target and whether to emit an unpacked directory.
  179. */
  180. export function parseDesktopPackageInvocation(
  181. argv: readonly string[],
  182. hostPlatform: NodeJS.Platform = process.platform,
  183. hostArch: string = process.arch,
  184. ): DesktopPackageInvocation {
  185. const { values, positionals } = parseArgs({
  186. args: [...argv],
  187. allowPositionals: true,
  188. options: {
  189. dir: { type: 'boolean', default: false },
  190. 'prepare-only': { type: 'boolean', default: false },
  191. unsigned: { type: 'boolean', default: false },
  192. check: { type: 'boolean', default: false },
  193. },
  194. })
  195. if (positionals.length > 1) throw new Error('desktop package: expected at most one target')
  196. const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch)
  197. if (values.unsigned && name !== 'win-x64') throw new Error('desktop package: --unsigned requires win-x64')
  198. if (values.unsigned && values['prepare-only']) throw new Error('desktop package: --unsigned cannot use --prepare-only')
  199. return {
  200. target: resolveDesktopPackageTarget(name, hostPlatform, hostArch),
  201. directory: values.dir,
  202. prepareOnly: values['prepare-only'],
  203. unsigned: values.unsigned,
  204. check: values.check,
  205. }
  206. }
  207. /**
  208. * Build the electron-builder command arguments for one validated target.
  209. * @param target - Supported release target.
  210. * @param directory - Whether to stop at an unpacked application directory.
  211. * @param artifact - Optional single artifact built from an existing signed application.
  212. * @returns Arguments that keep publishing under the separate validated upload command.
  213. */
  214. export function desktopElectronBuilderArguments(
  215. target: DesktopPackageTarget,
  216. directory: boolean,
  217. artifact?: DesktopPrepackagedArtifact,
  218. ): readonly string[] {
  219. return [
  220. 'exec',
  221. 'electron-builder',
  222. '--config',
  223. 'electron-builder.config.mjs',
  224. target.builderPlatform,
  225. ...(artifact === undefined ? [] : [artifact.format]),
  226. target.builderArch,
  227. '--publish',
  228. 'never',
  229. ...(directory ? ['--dir'] : []),
  230. ...(artifact === undefined ? [] : [
  231. ...(target.platform === 'darwin' ? ['--config.mac.notarize=false'] : []),
  232. '--prepackaged', artifact.appPath,
  233. '--config.directories.output', artifact.output,
  234. ]),
  235. ]
  236. }
  237. function runPnpm(
  238. args: readonly string[],
  239. env: NodeJS.ProcessEnv = process.env,
  240. cwd: string = APP_ROOT,
  241. run?: ReturnType<typeof createPackagingRun>,
  242. ): Promise<void> {
  243. const pnpmEntry = process.env.npm_execpath
  244. if (pnpmEntry === undefined || pnpmEntry === '') {
  245. throw new Error('desktop package: invoke this script through a pnpm package command')
  246. }
  247. if (run !== undefined) return run.run(args.join(' '), process.execPath, [pnpmEntry, ...args], { cwd, env })
  248. return new Promise((resolvePromise, reject) => {
  249. const child = spawn(process.execPath, [pnpmEntry, ...args], {
  250. cwd,
  251. env,
  252. stdio: 'inherit',
  253. })
  254. child.once('error', reject)
  255. child.once('close', (code, signal) => {
  256. if (code === 0) resolvePromise()
  257. else reject(new Error(`desktop package: pnpm ${args.join(' ')} exited with ${String(code ?? signal)}`))
  258. })
  259. })
  260. }
  261. async function main(): Promise<void> {
  262. const invocation = parseDesktopPackageInvocation(process.argv.slice(2))
  263. const { target } = invocation
  264. const environment = loadDesktopPackageEnvironment(target.platform)
  265. validateDesktopPackageEnvironment(environment, target, invocation)
  266. if (invocation.check) {
  267. process.stdout.write(`desktop package: ${target.name} local configuration valid; signing and notarization were not attempted\n`)
  268. return
  269. }
  270. const run = target.platform === 'win32'
  271. ? createPackagingRun(join(APP_ROOT, '.desktop-build', 'packaging-runs'), {
  272. target: target.name, unsigned: invocation.unsigned, version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
  273. }) : undefined
  274. if (run !== undefined) console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`)
  275. let success = false
  276. try {
  277. if (target.platform === 'darwin') {
  278. await withMacOSSigningKeychain(environment, signingEnvironment => packageTarget(invocation, signingEnvironment, run))
  279. } else {
  280. await packageTarget(invocation, environment, run)
  281. }
  282. success = true
  283. } finally { run?.finish(success) }
  284. }
  285. /**
  286. * Prepare one release only after its signing preflight, without publishing from the builder.
  287. * @param invocation Validated host, target and packaging mode.
  288. * @param environment File-owned release configuration.
  289. * @param run Windows stage supervisor; required for signed Windows packaging.
  290. * @returns Resolves after preparation or complete packaging; any failed stage prevents a release record.
  291. */
  292. export async function packageTarget(
  293. invocation: DesktopPackageInvocation,
  294. environment: NodeJS.ProcessEnv,
  295. run: ReturnType<typeof createPackagingRun> | undefined,
  296. ): Promise<void> {
  297. const { target } = invocation
  298. const execute = (args: readonly string[], env: NodeJS.ProcessEnv, cwd: string = APP_ROOT) => runPnpm(args, env, cwd, run)
  299. const buildPaths = desktopTargetBuildPaths(target.name)
  300. const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
  301. if (!invocation.prepareOnly && !invocation.unsigned) {
  302. rmSync(releaseRecordPath, { force: true })
  303. rmSync(`${releaseRecordPath}.tmp`, { force: true })
  304. }
  305. const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials(environment))
  306. const targetEnv: NodeJS.ProcessEnv = {
  307. ...buildEnv,
  308. DSH_DESKTOP_TARGET_PLATFORM: target.platform,
  309. DSH_DESKTOP_TARGET_ARCH: target.arch,
  310. }
  311. const electronBuilderEnv = desktopElectronBuilderEnvironment(targetEnv, invocation.unsigned)
  312. for (const name of WINDOWS_SIGNING_ENV_NAMES) {
  313. if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name]
  314. }
  315. const signPrimaryRuntime = target.platform === 'win32' && !invocation.unsigned && !invocation.prepareOnly
  316. if (signPrimaryRuntime) {
  317. if (run === undefined) throw new Error('desktop package: signed Windows packaging requires a supervised run')
  318. await run.run('preflight:windows-signing', process.execPath,
  319. ['--import', 'tsx/esm', join(APP_ROOT, 'scripts/windows-signing-preflight.ts')],
  320. { cwd: APP_ROOT, env: electronBuilderEnv, timeoutMs: 60_000 })
  321. }
  322. await execute(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
  323. await execute(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
  324. await execute([
  325. '--dir',
  326. 'apps/desktop-host',
  327. 'pack',
  328. '--pack-destination',
  329. buildPaths.packedDsh,
  330. ], buildEnv, REPOSITORY_ROOT)
  331. await execute(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT)
  332. rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
  333. mkdirSync(buildPaths.packedLandlock, { recursive: true })
  334. await execute(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
  335. await execute([
  336. '--dir',
  337. 'native/system/packages/entry',
  338. 'pack',
  339. '--pack-destination',
  340. buildPaths.packedLandlock,
  341. ], buildEnv, REPOSITORY_ROOT)
  342. await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], targetEnv)
  343. if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime'], electronBuilderEnv)
  344. await execute(['run', 'prepare:packages'], targetEnv)
  345. await execute(['run', 'prepare:dsh'], targetEnv)
  346. if (invocation.prepareOnly) return
  347. if (target.platform === 'darwin' && !invocation.directory) {
  348. await execute([
  349. ...desktopElectronBuilderArguments(target, true),
  350. '--config.mac.notarize=false',
  351. ], electronBuilderEnv)
  352. await packageMacOSArtifacts({
  353. arch: target.arch,
  354. version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
  355. artifactsRoot: buildPaths.artifacts,
  356. environment: electronBuilderEnv,
  357. }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv))
  358. } else {
  359. await execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
  360. }
  361. if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
  362. }
  363. if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()