desktop-package-environment.mjs 5.1 KB

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