OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / scripts / create-macos-dmg.mjs

162 lines · javascript ref:
#!/usr/bin/env node

import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'

const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
const args = parseArgs(process.argv.slice(2))
const version = args.tag ? versionFromTag(args.tag) : (args.version || pkg.version)
const appName = args.appName || 'PearBrowser'
const buildDir = resolve(args.buildDir || 'appling/build')
const out = resolve(args.out || join(buildDir, `${appName}-${version}.dmg`))
const appleId = process.env.PEARBROWSER_MACOS_NOTARY_APPLE_ID || ''
const password = process.env.PEARBROWSER_MACOS_NOTARY_PASSWORD || ''
const teamId = process.env.PEARBROWSER_MACOS_NOTARY_TEAM_ID || ''

if (version !== pkg.version) {
  fail(`release version ${version} does not match package.json version ${pkg.version}`)
}
if (process.platform !== 'darwin') {
  fail('macOS DMG creation requires macOS and hdiutil')
}
if (!existsSync(buildDir)) {
  fail(`build directory does not exist: ${buildDir}`)
}

const app = findAppBundle(buildDir, `${appName}.app`)
const staging = mkdtempSync(join(tmpdir(), 'pearbrowser-dmg-'))

try {
  run('ditto', [app, join(staging, basename(app))])
  symlinkSync('/Applications', join(staging, 'Applications'))
  rmSync(out, { force: true })
  run('hdiutil', [
    'create',
    '-volname',
    appName,
    '-srcfolder',
    staging,
    '-ov',
    '-format',
    'UDZO',
    out
  ])
  run('hdiutil', ['verify', out])

  if (appleId || password || teamId) {
    if (!appleId || !password || !teamId) {
      fail('DMG notarization requires PEARBROWSER_MACOS_NOTARY_APPLE_ID, PEARBROWSER_MACOS_NOTARY_PASSWORD, and PEARBROWSER_MACOS_NOTARY_TEAM_ID')
    }
    run('xcrun', [
      'notarytool',
      'submit',
      out,
      '--apple-id',
      appleId,
      '--password',
      password,
      '--team-id',
      teamId,
      '--wait'
    ])
    run('xcrun', ['stapler', 'staple', out])
    run('xcrun', ['stapler', 'validate', out])
  } else {
    console.log('Skipping DMG notarization; notarization credentials are not configured.')
  }

  const bytes = statSync(out).size
  if (bytes <= 0) fail(`created DMG is empty: ${out}`)
  console.log(`Created ${out} (${bytes} bytes)`)
} finally {
  rmSync(staging, { recursive: true, force: true })
}

function parseArgs (argv) {
  const options = new Map([
    ['--tag', 'tag'],
    ['--version', 'version'],
    ['--build-dir', 'buildDir'],
    ['--out', 'out'],
    ['--app-name', 'appName']
  ])
  const parsed = {}
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    const key = options.get(arg)
    if (!key) usage(2, `unknown argument: ${arg}`)
    const value = argv[++i] || ''
    if (!value || value.startsWith('--')) usage(2, `${arg} requires a value`)
    parsed[key] = value
  }
  return parsed
}

function usage (code, message = '') {
  if (message) console.error(`error: ${message}`)
  console.error('usage: node scripts/create-macos-dmg.mjs [--tag v0.5.0] [--build-dir appling/build] [--out path] [--app-name PearBrowser]')
  process.exit(code)
}

function versionFromTag (tag) {
  const normalized = String(tag || '').replace(/^refs\/tags\//, '')
  if (!/^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(normalized)) {
    usage(2, `release tag must look like vX.Y.Z, got ${tag}`)
  }
  return normalized.slice(1)
}

function findAppBundle (root, preferredName) {
  const matches = []
  walk(root)
  const preferred = matches.filter((path) => basename(path) === preferredName)
  const candidates = preferred.length ? preferred : matches
  if (candidates.length === 0) fail(`no .app bundle found under ${root}`)
  if (candidates.length > 1) fail(`multiple .app bundles found under ${root}: ${candidates.join(', ')}`)
  return candidates[0]

  function walk (dir) {
    for (const entry of readdirSync(dir, { withFileTypes: true })) {
      const path = join(dir, entry.name)
      if (!entry.isDirectory()) continue
      if (entry.name.endsWith('.app')) {
        matches.push(path)
        continue
      }
      if (shouldSkipDir(entry.name)) continue
      walk(path)
    }
  }
}

function shouldSkipDir (name) {
  return name === 'CMakeFiles' || name === '_deps' || name === '.cmake' || name === 'node_modules'
}

function run (command, args) {
  console.log(`$ ${[command, ...redact(args)].join(' ')}`)
  const result = spawnSync(command, args, { stdio: 'inherit' })
  if (result.error) throw result.error
  if (result.status !== 0) fail(`${command} exited with status ${result.status}`)
}

function redact (args) {
  const redacted = []
  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--password') {
      redacted.push(args[i], '********')
      i += 1
    } else {
      redacted.push(args[i])
    }
  }
  return redacted
}

function fail (message) {
  console.error(`error: ${message}`)
  process.exit(1)
}