1
0

make.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. /**
  2. * @author: oldj
  3. * @homepage: https://oldj.net
  4. */
  5. import chalk from 'chalk'
  6. import { config as loadEnv } from 'dotenv'
  7. import fse from 'fs-extra'
  8. import { createRequire } from 'node:module'
  9. import { homedir } from 'node:os'
  10. import path from 'node:path'
  11. import artifactBuildCompletedHook from './hooks/artifactBuildCompleted.mjs'
  12. import { PLATFORM_LABELS, formatDuration, logBanner, logPlatform, logStep, logSuccess, logWarning } from './libs/build-log.mjs'
  13. import { createBuildTracker, getBuildPlan } from './libs/build-plan.mjs'
  14. import { resolveMacBuildState, resolveWindowsBuildState } from './libs/build-state.mjs'
  15. import { resolveGithubRepository } from './release-config.mjs'
  16. import { APP_NAME, distDir, electronLanguages, rootDir } from './vars.mjs'
  17. loadEnv()
  18. // Use CommonJS require for local JSON/package reads so the script stays portable
  19. // across Node runtimes without relying on JSON import assertions.
  20. const require = createRequire(import.meta.url)
  21. const version = require('../src/version.json')
  22. const TARGET_PLATFORMS_CONFIGS = {
  23. mac: {
  24. mac: ['dmg:x64', 'dmg:arm64'],
  25. },
  26. win: {
  27. win: ['nsis:ia32', 'nsis:x64', 'nsis:arm64', 'portable:x64'],
  28. },
  29. linux: {
  30. linux: ['AppImage:x64', 'AppImage:arm64', 'deb:x64', 'deb:arm64'],
  31. },
  32. all: {
  33. mac: ['dmg:x64', 'dmg:arm64', 'zip:universal'],
  34. win: ['nsis:ia32', 'nsis:x64', 'nsis:arm64', 'portable:x64', 'zip:x64' /* , 'appx:x64'*/],
  35. linux: ['AppImage:x64', 'AppImage:arm64', 'deb:x64', 'deb:arm64'],
  36. },
  37. }
  38. const { APP_BUNDLE_ID, IDENTITY, MAKE_FOR } = process.env
  39. const appId = APP_BUNDLE_ID || 'SwitchHosts'
  40. const fullVersion = `${version[0]}.${version[1]}.${version[2]}.${version[3]}`
  41. const publishMode = process.env.PUBLISH_POLICY || 'never'
  42. const githubRepository = resolveGithubRepository(process.env)
  43. const WINDOWS_TIMESTAMP_SERVER = 'http://rfc3161timestamp.globalsign.com/advanced'
  44. function createBuilderConfig(hooks, macBuildState, winBuildState) {
  45. // Build the full electron-builder config in one place so every entrypoint
  46. // (`make`, `make:*`) stays on the same packaging pipeline.
  47. return {
  48. ...cfgCommon,
  49. appId,
  50. productName: APP_NAME,
  51. mac: {
  52. type: 'distribution',
  53. category: 'public.app-category.productivity',
  54. icon: 'assets/app.icns',
  55. gatekeeperAssess: false,
  56. electronLanguages,
  57. identity: macBuildState.sign ? IDENTITY : null,
  58. hardenedRuntime: true,
  59. entitlements: 'scripts/entitlements.mac.plist',
  60. entitlementsInherit: 'scripts/entitlements.mac.plist',
  61. extendInfo: {
  62. ITSAppUsesNonExemptEncryption: false,
  63. CFBundleLocalizations: electronLanguages,
  64. CFBundleDevelopmentRegion: 'en',
  65. },
  66. artifactName: '${productName}-v' + fullVersion + '-${arch}-mac.${ext}',
  67. ...(macBuildState.notarize ? {} : { notarize: false }),
  68. },
  69. dmg: {
  70. background: 'assets/dmg-bg.png',
  71. iconSize: 160,
  72. window: {
  73. width: 600,
  74. height: 420,
  75. },
  76. contents: [
  77. {
  78. x: 150,
  79. y: 200,
  80. },
  81. {
  82. x: 450,
  83. y: 200,
  84. type: 'link',
  85. path: '/Applications',
  86. },
  87. ],
  88. sign: macBuildState.sign,
  89. artifactName: '${productName}-v' + fullVersion + '-mac-${arch}.${ext}',
  90. },
  91. win: {
  92. icon: 'assets/icon.ico',
  93. verifyUpdateCodeSignature: winBuildState.sign,
  94. signAndEditExecutable: winBuildState.sign,
  95. // NSIS/portable targets still try to sign final `.exe` artifacts unless
  96. // we explicitly exclude them when Windows signing is disabled.
  97. ...(winBuildState.sign ? {} : { signExts: ['!.exe'] }),
  98. ...(winBuildState.sign
  99. ? {
  100. signtoolOptions: {
  101. signingHashAlgorithms: ['sha256'],
  102. publisherName: winBuildState.publisherName,
  103. certificateSubjectName: winBuildState.certificateSubjectName,
  104. timeStampServer: WINDOWS_TIMESTAMP_SERVER,
  105. rfc3161TimeStampServer: WINDOWS_TIMESTAMP_SERVER,
  106. },
  107. }
  108. : {}),
  109. artifactName: '${productName}-v' + fullVersion + '-win-${arch}.${ext}',
  110. },
  111. nsis: {
  112. installerIcon: 'assets/installer-icon.ico',
  113. oneClick: false,
  114. allowToChangeInstallationDirectory: true,
  115. deleteAppDataOnUninstall: false,
  116. shortcutName: 'SwitchHosts',
  117. artifactName: '${productName}-v' + fullVersion + '-win-${arch}-installer.${ext}',
  118. },
  119. portable: {
  120. artifactName: '${productName}-v' + fullVersion + '-win-${arch}-portable.${ext}',
  121. },
  122. linux: {
  123. icon: 'assets/app.icns',
  124. artifactName: '${productName}-v' + fullVersion + '-linux-${arch}.${ext}',
  125. category: 'Utility',
  126. synopsis: 'An App for hosts management and switching.',
  127. desktop: {
  128. entry: {
  129. Name: 'SwitchHosts',
  130. Type: 'Application',
  131. GenericName: 'An App for hosts management and switching.',
  132. },
  133. },
  134. },
  135. publish: {
  136. // Keep the GitHub provider configured so electron-builder emits update metadata
  137. // for GitHub Releases, while the actual asset upload stays in scripts/upload-release.mjs.
  138. provider: 'github',
  139. owner: githubRepository.owner,
  140. repo: githubRepository.repo,
  141. releaseType: 'draft',
  142. vPrefixedTagName: true,
  143. },
  144. beforePack: hooks.beforePack,
  145. afterPack: hooks.afterPack,
  146. artifactBuildCompleted: hooks.artifactBuildCompleted,
  147. }
  148. }
  149. if (!APP_BUNDLE_ID) {
  150. logWarning('APP_BUNDLE_ID is not set, falling back to appId "SwitchHosts".')
  151. }
  152. logStep(`APP_BUNDLE_ID: ${APP_BUNDLE_ID || '(fallback: SwitchHosts)'}`)
  153. const cfgCommon = {
  154. copyright: `Copyright © ${new Date().getFullYear()}`,
  155. buildVersion: version[3].toString(),
  156. directories: {
  157. buildResources: 'build',
  158. app: 'build',
  159. output: 'dist',
  160. },
  161. electronDownload: {
  162. cache: path.join(homedir(), '.electron'),
  163. mirror: 'https://registry.npmmirror.com/-/binary/electron/',
  164. },
  165. asar: true,
  166. compression: 'maximum',
  167. }
  168. const beforeMake = async () => {
  169. const t0 = Date.now()
  170. logBanner('Prepare Build Directory')
  171. // Start every package run from a clean dist directory to avoid mixing artifacts
  172. // from different target sets or previous versions.
  173. fse.removeSync(distDir)
  174. fse.ensureDirSync(distDir)
  175. logStep(`dist cleaned: ${distDir}`)
  176. const toCopy = [[path.join(rootDir, 'assets', 'app.png'), path.join(rootDir, 'build', 'assets', 'app.png')]]
  177. toCopy.map(([src, target]) => {
  178. fse.copySync(src, target)
  179. })
  180. logStep(`copied build assets: ${toCopy.map(([src]) => path.basename(src)).join(', ')}`)
  181. let pkgBase = require(path.join(rootDir, 'package.json'))
  182. let pkgApp = require(path.join(rootDir, 'app', 'package.json'))
  183. // Refresh the app package manifest inside build/ so electron-builder always
  184. // packages the current dependency set and release version.
  185. pkgApp.name = APP_NAME
  186. pkgApp.version = version.slice(0, 3).join('.')
  187. pkgApp.dependencies = pkgBase.dependencies
  188. fse.writeFileSync(
  189. path.join(rootDir, 'build', 'package.json'),
  190. JSON.stringify(pkgApp, null, 2),
  191. 'utf-8',
  192. )
  193. logSuccess(`build/package.json refreshed in ${formatDuration(Date.now() - t0)}`)
  194. }
  195. const afterMake = async () => {
  196. const t0 = Date.now()
  197. logBanner('Finalize Packaging')
  198. // Reserved for post-build cleanup or metadata fixes if packaging needs them later.
  199. logSuccess(`post-build steps finished in ${formatDuration(Date.now() - t0)}`)
  200. }
  201. const doMake = async () => {
  202. // Resolve the requested platform set first so every later step can log against
  203. // the same plan and timing model.
  204. const compression = MAKE_FOR === 'dev' ? 'store' : 'maximum'
  205. cfgCommon.compression = compression
  206. const plan = getBuildPlan(MAKE_FOR, TARGET_PLATFORMS_CONFIGS)
  207. const macBuildState = await resolveMacBuildState(plan)
  208. const winBuildState = resolveWindowsBuildState(plan)
  209. const tracker = createBuildTracker({
  210. plan,
  211. compression,
  212. macBuildState,
  213. winBuildState,
  214. artifactBuildCompletedHook,
  215. })
  216. logBanner('Build Plan')
  217. logStep(`MAKE_FOR: ${MAKE_FOR || 'all'}`)
  218. logStep(`version: ${fullVersion}`)
  219. logStep(`appId: ${appId}`)
  220. logStep(`compression: ${cfgCommon.compression}`)
  221. logStep(`publish: ${publishMode}`)
  222. logStep(`platforms: ${plan.map(({ platform }) => PLATFORM_LABELS[platform]).join(', ')}`)
  223. if (macBuildState.includesMac) {
  224. if (macBuildState.logLevel === 'warning') {
  225. logWarning(macBuildState.message)
  226. } else if (macBuildState.logLevel === 'success') {
  227. logSuccess(macBuildState.message)
  228. } else {
  229. logStep(macBuildState.message)
  230. }
  231. }
  232. if (winBuildState.includesWin) {
  233. if (winBuildState.logLevel === 'warning') {
  234. logWarning(winBuildState.message)
  235. } else if (winBuildState.logLevel === 'success') {
  236. logSuccess(winBuildState.message)
  237. } else {
  238. logStep(winBuildState.message)
  239. }
  240. }
  241. if (macBuildState.notarize) {
  242. logStep('notarization environment prepared')
  243. } else if (macBuildState.includesMac) {
  244. logStep('running macOS packaging without notarization')
  245. } else {
  246. logStep('skipping macOS notarization preparation')
  247. }
  248. logStep('loading electron-builder...')
  249. const eb = await import('electron-builder')
  250. const builder = eb.default || eb
  251. logSuccess('electron-builder loaded')
  252. // Build one platform per invocation so electron-builder's own logs stay grouped
  253. // and easy to read even when each platform expands to multiple arch/target jobs.
  254. for (const { platform, targets } of plan) {
  255. logPlatform(platform, 'starting electron-builder run...')
  256. await builder.build({
  257. [platform]: targets,
  258. publish: publishMode,
  259. config: createBuilderConfig(tracker.hooks, macBuildState, winBuildState),
  260. })
  261. logPlatform(platform, 'electron-builder run finished.')
  262. }
  263. tracker.printSummary()
  264. }
  265. async function main() {
  266. const t0 = Date.now()
  267. try {
  268. // The top-level flow is intentionally linear: prepare inputs, run packaging,
  269. // then finish with summary output and any future cleanup.
  270. await beforeMake()
  271. await doMake()
  272. await afterMake()
  273. logBanner('Done')
  274. logSuccess(`total elapsed: ${formatDuration(Date.now() - t0)}`)
  275. } catch (e) {
  276. logBanner('Build Failed')
  277. console.error(chalk.red(e?.stack || String(e)))
  278. console.log(chalk.red(`total elapsed before failure: ${formatDuration(Date.now() - t0)}`))
  279. process.exit(1)
  280. }
  281. }
  282. await main()