package-target.ts 13 KB

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