desktop-package-environment.mjs 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. /** Load platform-local release settings without changing the caller's process environment. */
  2. import { accessSync, constants, readFileSync, statSync } from 'node:fs'
  3. import { dirname, join, resolve } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { parseEnv } from 'node:util'
  6. import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment } from './desktop-release-environment.mjs'
  7. import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
  8. import { createWindowsTokenSigner } from './windows-sign.mjs'
  9. import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
  10. const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
  11. const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
  12. const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u
  13. const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
  14. const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
  15. const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
  16. /**
  17. * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values.
  18. * @param {'win32' | 'darwin'} platform Target platform.
  19. * @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools.
  20. * @param {string} appRoot Desktop application directory; relative credential paths resolve here.
  21. * @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings.
  22. */
  23. export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT) {
  24. const path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos')
  25. let contents
  26. try {
  27. contents = readFileSync(path, 'utf8')
  28. }
  29. catch {
  30. throw new Error(`desktop package: cannot read ${path}; copy ${path}.example and fill in the local settings`)
  31. }
  32. let settings
  33. try {
  34. settings = parseEnv(contents.replace(/^\uFEFF/u, ''))
  35. }
  36. catch {
  37. // Parser diagnostics can contain credential-bearing input.
  38. throw new Error(`desktop package: invalid dotenv syntax in ${path}`)
  39. }
  40. const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING
  41. for (const name of Object.keys(settings)) {
  42. if (!SHARED_SETTING.test(name) && !platformSetting.test(name)) {
  43. throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`)
  44. }
  45. if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`)
  46. }
  47. for (const name of FILE_SETTINGS) {
  48. if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim())
  49. }
  50. return {
  51. ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))),
  52. ...settings,
  53. }
  54. }
  55. function requireReadableFile(environment, name) {
  56. try {
  57. if (!statSync(environment[name]).isFile()) throw new Error('not a file')
  58. accessSync(environment[name], constants.R_OK)
  59. }
  60. catch {
  61. throw new Error(`desktop package: ${name} must identify a readable local file`)
  62. }
  63. }
  64. /**
  65. * Validate release configuration before preparation without invoking a token or Apple's services.
  66. * @param {NodeJS.ProcessEnv} environment File-owned release settings.
  67. * @param {{ platform: 'win32' | 'darwin', arch: string }} target Selected release target.
  68. * @param {{ unsigned?: boolean, prepareOnly?: boolean }} options Explicit packaging mode.
  69. * @returns {void}
  70. */
  71. export function validateDesktopPackageEnvironment(environment, target, options = {}) {
  72. resolveDesktopAppId(environment)
  73. resolveDesktopPolicyEnvironment(environment)
  74. if (options.unsigned) return
  75. if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
  76. if (target.platform === 'win32') {
  77. if (!options.prepareOnly) createWindowsTokenSigner({
  78. certificateFile: environment.DSH_DESKTOP_WINDOWS_CER_FILE,
  79. signTool: environment.DSH_DESKTOP_WINDOWS_SIGNTOOL,
  80. tokenPin: environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
  81. keyContainer: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
  82. })
  83. } else {
  84. resolveMacOSSigningEnvironment(environment)
  85. const strategies = [
  86. ['APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID'],
  87. ['APPLE_API_KEY', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER'],
  88. ['APPLE_KEYCHAIN_PROFILE', 'APPLE_KEYCHAIN'],
  89. ]
  90. if (strategies.filter(names => names.some(name => environment[name] !== undefined)).length > 1) {
  91. throw new Error('desktop package: configure exactly one macOS notarization strategy; comment out the other strategies')
  92. }
  93. const credentials = resolveMacOSNotarizationEnvironment(environment)
  94. if ('appleApiKey' in credentials) requireReadableFile(environment, 'APPLE_API_KEY')
  95. if ('keychain' in credentials) requireReadableFile(environment, 'APPLE_KEYCHAIN')
  96. requireReadableFile(environment, 'CSC_LINK')
  97. if (environment.CSC_KEY_PASSWORD === undefined) {
  98. throw new Error('desktop package: CSC_KEY_PASSWORD must be set to the p12 export password (use an explicit empty value for an unencrypted p12)')
  99. }
  100. }
  101. }