package-target.ts 10 KB

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