package-target.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  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. ...(target.platform === 'darwin' ? ['--config.mac.notarize=false'] : []),
  226. '--prepackaged', artifact.appPath,
  227. '--config.directories.output', artifact.output,
  228. ]),
  229. ]
  230. }
  231. function runPnpm(
  232. args: readonly string[],
  233. env: NodeJS.ProcessEnv = process.env,
  234. cwd: string = APP_ROOT,
  235. ): Promise<void> {
  236. const pnpmEntry = process.env.npm_execpath
  237. if (pnpmEntry === undefined || pnpmEntry === '') {
  238. throw new Error('desktop package: invoke this script through a pnpm package command')
  239. }
  240. return new Promise((resolvePromise, reject) => {
  241. const child = spawn(process.execPath, [pnpmEntry, ...args], {
  242. cwd,
  243. env,
  244. stdio: 'inherit',
  245. })
  246. child.once('error', reject)
  247. child.once('close', (code, signal) => {
  248. if (code === 0) resolvePromise()
  249. else reject(new Error(`desktop package: pnpm ${args.join(' ')} exited with ${String(code ?? signal)}`))
  250. })
  251. })
  252. }
  253. async function main(): Promise<void> {
  254. const invocation = parseDesktopPackageInvocation(process.argv.slice(2))
  255. const { target } = invocation
  256. const buildPaths = desktopTargetBuildPaths(target.name)
  257. const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
  258. if (!invocation.prepareOnly && !invocation.unsigned) {
  259. rmSync(releaseRecordPath, { force: true })
  260. rmSync(`${releaseRecordPath}.tmp`, { force: true })
  261. }
  262. const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials(process.env))
  263. const targetEnv: NodeJS.ProcessEnv = {
  264. ...buildEnv,
  265. DSH_DESKTOP_TARGET_PLATFORM: target.platform,
  266. DSH_DESKTOP_TARGET_ARCH: target.arch,
  267. }
  268. const electronBuilderEnv = desktopElectronBuilderEnvironment(targetEnv, invocation.unsigned)
  269. for (const name of WINDOWS_SIGNING_ENV_NAMES) {
  270. if (!invocation.unsigned && process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name]
  271. }
  272. await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
  273. await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
  274. await runPnpm([
  275. '--dir',
  276. 'apps/desktop-host',
  277. 'pack',
  278. '--pack-destination',
  279. buildPaths.packedDsh,
  280. ], buildEnv, REPOSITORY_ROOT)
  281. await runPnpm(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT)
  282. rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
  283. mkdirSync(buildPaths.packedLandlock, { recursive: true })
  284. await runPnpm(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
  285. await runPnpm([
  286. '--dir',
  287. 'native/system/packages/entry',
  288. 'pack',
  289. '--pack-destination',
  290. buildPaths.packedLandlock,
  291. ], buildEnv, REPOSITORY_ROOT)
  292. await runPnpm(['run', 'prepare:runtime'], targetEnv)
  293. await runPnpm(['run', 'prepare:packages'], targetEnv)
  294. await runPnpm(['run', 'prepare:dsh'], targetEnv)
  295. if (invocation.prepareOnly) return
  296. if (target.platform === 'darwin' && !invocation.directory) {
  297. await runPnpm([
  298. ...desktopElectronBuilderArguments(target, true),
  299. '--config.mac.notarize=false',
  300. ], electronBuilderEnv)
  301. await packageMacOSArtifacts({
  302. arch: target.arch,
  303. version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
  304. artifactsRoot: buildPaths.artifacts,
  305. environment: electronBuilderEnv,
  306. }, artifact => runPnpm(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv))
  307. } else {
  308. await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
  309. }
  310. if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
  311. }
  312. if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()