const path = require('path'); const fs = require('fs'); const builder = require('electron-builder'); const { Arch } = require('electron-builder'); const { execFileSync, spawnSync } = require('child_process'); const { author } = require('../package.json'); const { signWindows } = require('./sign-windows.cjs'); const noCompression = process.argv.includes('--no-compression'); const shouldNotarize = process.argv.includes('--notarize'); const shouldSign = process.argv.includes('--sign'); const universal = process.argv.includes('--universal'); const release = process.argv.includes('--release'); const targets = getOptionValue('--targets'); const hostTargetTriple = getOptionValue('--host-target-triple'); function getOptionValue(option) { const optionIndex = process.argv.indexOf(option); if (optionIndex !== -1) { return process.argv[optionIndex + 1]; } } function newConfig() { return { appId: 'net.mullvad.vpn', copyright: 'Mullvad VPN AB', productName: 'Mullvad VPN', publish: null, asar: true, compression: noCompression ? 'store' : 'normal', extraResources: [ { from: distAssets('ca.crt'), to: '.' }, { from: distAssets('relays/relays.json'), to: '.' }, { from: root('CHANGELOG.md'), to: '.' }, ], directories: { buildResources: root('dist-assets'), output: root('dist'), }, extraMetadata: { name: 'mullvad-vpn', version: productVersion(), }, files: [ 'package.json', 'changes.txt', 'build/', '!**/*.tsbuildinfo', '!test/', '!playwright.config.ts', 'node_modules/', '!node_modules/grpc-tools', '!node_modules/@types', '!node_modules/nseventforwarder/debug', '!node_modules/windows-utils/debug', ], // Make sure that all files declared in "extraResources" exists and abort if they don't. afterPack: (context) => { if (context.arch !== Arch.universal) { const resources = context.packager.platformSpecificBuildOptions.extraResources; for (const resource of resources) { const filePath = resource.from.replaceAll( /\$\{env\.(.*?)\}/g, function (match, captureGroup) { return process.env[captureGroup]; }, ); if (!fs.existsSync(filePath)) { throw new Error(`Can't find file: ${filePath}`); } } } }, mac: { target: { target: 'pkg', arch: getMacArch(), }, x64ArchFiles: 'Contents/Resources/app.asar.unpacked/node_modules/nseventforwarder/dist/*/index.node', artifactName: 'MullvadVPN-${version}.${ext}', category: 'public.app-category.tools', icon: distAssets('icon-macos.icns'), notarize: shouldNotarize, extendInfo: { LSUIElement: true, NSUserNotificationAlertStyle: 'banner', }, extraResources: [ { from: distAssets(path.join('${env.BINARIES_PATH}', 'mullvad')), to: '.' }, { from: distAssets(path.join('${env.BINARIES_PATH}', 'mullvad-problem-report')), to: '.' }, { from: distAssets(path.join('${env.BINARIES_PATH}', 'mullvad-daemon')), to: '.' }, { from: distAssets(path.join('${env.BINARIES_PATH}', 'mullvad-setup')), to: '.' }, { from: distAssets('uninstall_macos.sh'), to: './uninstall.sh' }, { from: buildAssets('shell-completions/_mullvad'), to: '.' }, { from: buildAssets('shell-completions/mullvad.fish'), to: '.' }, { from: buildAssets('shell-completions/mullvad.nu'), to: '.' }, ], }, pkg: { allowAnywhere: false, allowCurrentUserHome: false, isRelocatable: false, isVersionChecked: false, }, nsis: { guid: '2A356FD4-03B7-4F45-99B4-737BE580DC82', oneClick: false, perMachine: true, allowElevation: true, allowToChangeInstallationDirectory: false, include: distAssets('windows/installer.nsh'), installerSidebar: distAssets('windows/installersidebar.bmp'), }, win: { target: [], artifactName: 'MullvadVPN-${version}_${arch}.${ext}', extraResources: [ { from: distAssets(path.join('${env.DIST_SUBDIR}', 'mullvad.exe')), to: '.' }, { from: distAssets(path.join('${env.DIST_SUBDIR}', 'mullvad-problem-report.exe')), to: '.', }, { from: distAssets(path.join('${env.DIST_SUBDIR}', 'mullvad-daemon.exe')), to: '.' }, { from: distAssets(path.join('${env.DIST_SUBDIR}', 'mullvad-setup.exe')), to: '.', }, { from: root( path.join( 'windows', 'winfw', 'bin', '${env.TARGET_ARCHITECTURE}-${env.CPP_BUILD_MODE}', 'winfw.dll', ), ), to: '.', }, { from: distAssets(path.join('binaries', '${env.TARGET_SUBDIR}', 'wintun/wintun.dll')), to: '.', }, { from: distAssets( path.join('binaries', '${env.TARGET_SUBDIR}', 'split-tunnel/mullvad-split-tunnel.sys'), ), to: '.', }, { from: distAssets( path.join('binaries', '${env.TARGET_SUBDIR}', 'wireguard-nt/wireguard.dll'), ), to: '.', }, ], }, linux: { target: [ { target: 'deb', arch: getLinuxTargetArch(), }, { target: 'rpm', arch: getLinuxTargetArch(), }, ], executableName: 'mullvad-vpn', artifactName: 'MullvadVPN-${version}_${arch}.${ext}', category: 'Network', icon: distAssets('icon.icns'), extraFiles: [{ from: distAssets('linux/mullvad-gui-launcher.sh'), to: '.' }], extraResources: [ { from: distAssets(path.join(getLinuxTargetSubdir(), 'mullvad-problem-report')), to: '.' }, { from: distAssets(path.join(getLinuxTargetSubdir(), 'mullvad-setup')), to: '.' }, { from: distAssets(path.join('linux', 'apparmor_mullvad')), to: '.' }, ], }, deb: { fpm: [ '--no-depends', '--version', getLinuxVersion(), '--before-install', distAssets('linux/before-install.sh'), '--before-remove', distAssets('linux/before-remove.sh'), distAssets('linux/mullvad-daemon.service') + '=/usr/lib/systemd/system/mullvad-daemon.service', distAssets('linux/mullvad-early-boot-blocking.service') + '=/usr/lib/systemd/system/mullvad-early-boot-blocking.service', distAssets(path.join(getLinuxTargetSubdir(), 'mullvad')) + '=/usr/bin/', distAssets(path.join(getLinuxTargetSubdir(), 'mullvad-daemon')) + '=/usr/bin/', distAssets(path.join(getLinuxTargetSubdir(), 'mullvad-exclude')) + '=/usr/bin/', distAssets('linux/problem-report-link') + '=/usr/bin/mullvad-problem-report', buildAssets('shell-completions/mullvad.bash') + '=/usr/share/bash-completion/completions/mullvad', buildAssets('shell-completions/_mullvad') + '=/usr/local/share/zsh/site-functions/_mullvad', buildAssets('shell-completions/mullvad.fish') + '=/usr/share/fish/vendor_completions.d/mullvad.fish', buildAssets('shell-completions/mullvad.nu') + '=/usr/share/nushell/vendor/autoload/mullvad.nu', ], afterInstall: distAssets('linux/after-install.sh'), afterRemove: distAssets('linux/after-remove.sh'), }, rpm: { fpm: [ '--version', getLinuxVersion(), // Prevents RPM from packaging build-id metadata, some of which is the // same across all electron-builder applications, which causes package // conflicts '--rpm-rpmbuild-define=_build_id_links none', // Required for reproducible rpm output. Unlike fpm's deb output, which is // deterministic when SOURCE_DATE_EPOCH is set (exported by build.sh), rpmbuild must // be explicitly told to clamp payload file mtimes and the BUILDTIME header to it. '--rpm-rpmbuild-define=clamp_mtime_to_source_date_epoch 1', '--rpm-rpmbuild-define=use_source_date_epoch_as_buildtime 1', // Set the BUILDHOST header to a fixed value instead of the build machine's hostname '--rpm-rpmbuild-define=_buildhost reproducible', // Written by writeRpmChangelog. The entry fpm generates on its own carries the build date '--rpm-changelog', rpmChangelogPath(), '--directories=/opt/Mullvad VPN/', '--before-install', distAssets('linux/before-install.sh'), '--before-remove', distAssets('linux/before-remove.sh'), '--rpm-posttrans', distAssets('linux/post-transaction.sh'), distAssets('linux/mullvad-daemon.service') + '=/usr/lib/systemd/system/mullvad-daemon.service', distAssets('linux/mullvad-early-boot-blocking.service') + '=/usr/lib/systemd/system/mullvad-early-boot-blocking.service', distAssets(path.join(getLinuxTargetSubdir(), 'mullvad')) + '=/usr/bin/', distAssets(path.join(getLinuxTargetSubdir(), 'mullvad-daemon')) + '=/usr/bin/', distAssets(path.join(getLinuxTargetSubdir(), 'mullvad-exclude')) + '=/usr/bin/', distAssets('linux/problem-report-link') + '=/usr/bin/mullvad-problem-report', buildAssets('shell-completions/mullvad.bash') + '=/usr/share/bash-completion/completions/mullvad', buildAssets('shell-completions/_mullvad') + '=/usr/share/zsh/site-functions/_mullvad', buildAssets('shell-completions/mullvad.fish') + '=/usr/share/fish/vendor_completions.d/mullvad.fish', buildAssets('shell-completions/mullvad.nu') + '=/usr/share/nushell/vendor/autoload/mullvad.nu', ], afterInstall: distAssets('linux/after-install.sh'), afterRemove: distAssets('linux/after-remove.sh'), depends: ['libXScrnSaver', 'libnotify', 'dbus-libs'], }, }; } const WINDOWS_SIGNING_OPTIONS = { signExts: ['.dll', '.node'], signtoolOptions: { sign: signWindows, // Our signing script produces a single SHA-256 signature. Without this, electron-builder // calls the hook twice per file, expecting a second, SHA-1, signature to be appended. signingHashAlgorithms: ['sha256'], }, }; async function packWin() { const DEFAULT_ARCH = targets === 'aarch64-pc-windows-msvc' ? 'arm64' : 'x64'; function prepareWinConfig(arch) { const config = newConfig(); return { ...config, // Never produce an unsigned artifact when signing was asked for. Without this, // electron-builder logs failures to sign individual files and carries on. forceCodeSigning: shouldSign, win: { ...config.win, target: [ { target: 'nsis', arch: arch, }, ], ...(shouldSign ? WINDOWS_SIGNING_OPTIONS : {}), }, asarUnpack: ['build/assets/images/menubar-icons/win32/lock-*.ico', '**/*.node'], beforeBuild: (options) => { process.env.CPP_BUILD_MODE = release ? 'Release' : 'Debug'; process.env.CPP_BUILD_TARGET = options.arch; process.env.TARGET_ARCHITECTURE = options.arch; switch (options.arch) { case 'x64': process.env.TARGET_TRIPLE = 'x86_64-pc-windows-msvc'; process.env.SETUP_SUBDIR = '.'; process.env.TARGET_SUBDIR = 'x86_64-pc-windows-msvc'; process.env.DIST_SUBDIR = ''; execFileSync('npm', ['-w', 'windows-utils', 'run', 'build-x86'], { shell: true }); break; case 'arm64': process.env.TARGET_TRIPLE = 'aarch64-pc-windows-msvc'; process.env.SETUP_SUBDIR = 'aarch64-pc-windows-msvc'; process.env.TARGET_SUBDIR = 'aarch64-pc-windows-msvc'; process.env.DIST_SUBDIR = 'aarch64-pc-windows-msvc'; execFileSync('npm', ['-w', 'windows-utils', 'run', 'build-arm'], { shell: true }); break; default: throw new Error('Invalid or unknown target (only one may be specified)'); } return true; }, }; } if (universal) { // For universal builds, we simply build for all targets. It is up to build.sh to pack the // installers in the same binary. await builder.build({ targets: builder.Platform.WINDOWS.createTarget(), config: prepareWinConfig(DEFAULT_ARCH === 'x64' ? 'arm64' : 'x64'), }); } return builder.build({ targets: builder.Platform.WINDOWS.createTarget(), config: prepareWinConfig(DEFAULT_ARCH), }); } async function packMac() { const appOutDirs = []; function prepareMacConfig(arch, artifactName) { const config = newConfig(); // For a universal build, electron-builder packs an x64 and an arm64 sub-app and then merges // them with @electron/universal. Both sub-packs run beforeBuild/beforePack with a concrete // arch (x64/arm64). const isUniversal = arch === 'universal'; // Different archs have different cdhashes. const pkgScriptsDir = buildAssets(`pkg-scripts-${arch ?? 'host'}`); return { ...config, mac: { ...config.mac, target: { target: 'pkg', arch, }, artifactName, }, pkg: { ...config.pkg, scripts: pkgScriptsDir, }, asarUnpack: ['**/*.node'], beforeBuild: async (options) => { switch (options.arch) { case 'x64': process.env.TARGET_TRIPLE = 'x86_64-apple-darwin'; break; case 'arm64': process.env.TARGET_TRIPLE = 'aarch64-apple-darwin'; break; default: delete process.env.TARGET_TRIPLE; break; } // For a universal build, @electron/universal requires the module for *both* architectures // to be present in *both* sub-packs (it leaves them un-merged via mac.x64ArchFiles), so // build both here. A single-arch build only needs its own. if (isUniversal || options.arch === 'x64') { execFileSync('npm', ['-w', 'nseventforwarder', 'run', 'build-x86']); } if (isUniversal || options.arch === 'arm64') { execFileSync('npm', ['-w', 'nseventforwarder', 'run', 'build-arm']); } process.env.BINARIES_PATH = hostTargetTriple !== process.env.TARGET_TRIPLE ? process.env.TARGET_TRIPLE : ''; return true; }, beforePack: async (context) => { if (!isUniversal) { // Ensure we don't pack native modules for other architectures. // These will exist if the app has been built for other architectures before. await removeNseventforwarderNativeModules(); } config.beforePack?.(context); fs.rmSync(pkgScriptsDir, { recursive: true, force: true }); if (process.env.CSC_IDENTITY_AUTO_DISCOVERY !== 'true') { // afterSign is never called if signing is disabled. writePkgScripts(pkgScriptsDir, ''); } }, afterPack: (context) => { config.afterPack?.(context); if (context.arch !== Arch.universal) { delete process.env.TARGET_TRIPLE; appOutDirs.push(context.appOutDir); } return Promise.resolve(); }, afterAllArtifactBuild: async (_buildResult) => { const postinstall = fs.readFileSync(path.join(pkgScriptsDir, 'postinstall'), 'utf8'); if (postinstall.includes(DAEMON_SPAWN_CONSTRAINT_PLACEHOLDER)) { throw new Error('Daemon spawn constraint was not added to postinstall'); } // Remove the folder that contains the unpacked app. Electron builder cleans up some of // these directories and it's changed between versions without a mention in the changelog. for (const dir of appOutDirs) { try { await fs.promises.rm(dir, { recursive: true }); } catch { // noop } } }, afterSign: (context) => { const appOutDir = context.appOutDir; appOutDirs.push(appOutDir); // Only merged app is included in pkg. if (isUniversal && context.arch !== Arch.universal) { return; } const daemonPath = path.join( appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents/Resources/mullvad-daemon', ); writePkgScripts(pkgScriptsDir, daemonSpawnConstraint(daemonPath)); }, }; } if (universal) { // In addition to the universal installer, produce single-architecture pkgs. The binaries // for both architectures have already been built by build.sh. await builder.build({ targets: builder.Platform.MAC.createTarget(), config: prepareMacConfig('x64', 'MullvadVPN-${version}_x86_64.${ext}'), }); await builder.build({ targets: builder.Platform.MAC.createTarget(), config: prepareMacConfig('arm64', 'MullvadVPN-${version}_arm64.${ext}'), }); } return builder.build({ targets: builder.Platform.MAC.createTarget(), config: prepareMacConfig(getMacArch(), 'MullvadVPN-${version}.${ext}'), }); } const DAEMON_SPAWN_CONSTRAINT_PLACEHOLDER = '@DAEMON_SPAWN_CONSTRAINT@'; // Copy the pkg scripts to `outDir`, replacing the spawn constraint placeholder in postinstall with // `spawnConstraint`. If `spawnConstraint` is null, the placeholder is kept. function writePkgScripts(outDir, spawnConstraint) { fs.rmSync(outDir, { recursive: true, force: true }); fs.cpSync(distAssets('pkg-scripts'), outDir, { recursive: true }); const postinstallPath = path.join(outDir, 'postinstall'); const postinstall = fs.readFileSync(postinstallPath, 'utf8'); fs.writeFileSync( postinstallPath, postinstall.replace(DAEMON_SPAWN_CONSTRAINT_PLACEHOLDER, () => spawnConstraint), ); } // Return a launchd spawn constraint that only allows the daemon at `daemonPath` to be launched, // identified by its cdhash. A universal binary has one cdhash per architecture. function daemonSpawnConstraint(daemonPath) { const archs = execFileSync('lipo', ['-archs', daemonPath], { encoding: 'utf8' }) .trim() .split(/\s+/); const cdhashes = archs.map((arch) => { // codesign prints the signature info to stderr const { stderr } = spawnSync('codesign', ['--display', '-vvv', '--arch', arch, daemonPath], { encoding: 'utf8', }); const match = stderr.match(/^CDHash=([0-9a-f]+)$/m); if (!match) { throw new Error(`Failed to get cdhash of ${daemonPath} (${arch})`); } return Buffer.from(match[1], 'hex').toString('base64'); }); const value = cdhashes.length === 1 ? `${cdhashes[0]}` : `\\$in${cdhashes.map((cdhash) => `${cdhash}`).join('')}`; return `SpawnConstraintcdhash${value}`; } function packLinux() { writeRpmChangelog(); const config = newConfig(); if (noCompression) { config.rpm.fpm.unshift('--rpm-compression', 'none'); } if (targets && targets === 'aarch64-unknown-linux-gnu') { config.rpm.fpm.unshift('--architecture', 'aarch64'); } return builder.build({ targets: builder.Platform.LINUX.createTarget(), config: { ...config, beforeBuild: (options) => { switch (options.arch) { case 'x64': process.env.TARGET_TRIPLE = 'x86_64-unknown-linux-gnu'; break; case 'arm64': process.env.TARGET_TRIPLE = 'aarch64-unknown-linux-gnu'; break; default: delete process.env.TARGET_TRIPLE; break; } return true; }, afterPack: async (context) => { config.afterPack?.(context); const sourceExecutable = path.join(context.appOutDir, 'mullvad-vpn'); const targetExecutable = path.join(context.appOutDir, 'mullvad-gui'); const launcherScript = path.join(context.appOutDir, 'mullvad-gui-launcher.sh'); // rename "Mullvad VPN" to mullvad-gui await fs.promises.rename(sourceExecutable, targetExecutable); // rename launcher script to mullvad-vpn await fs.promises.rename(launcherScript, sourceExecutable); }, }, }); } function buildAssets(relativePath) { return root(path.join('build', relativePath)); } function distAssets(relativePath) { return root(path.join('dist-assets', relativePath)); } function root(relativePath) { return path.join(path.resolve(__dirname, '../../../../'), relativePath); } function getLinuxTargetArch() { if (targets && process.platform === 'linux') { if (targets === 'aarch64-unknown-linux-gnu') { return 'arm64'; } throw new Error('Invalid or unknown target (only one may be specified)'); } // Use host architecture. return undefined; } function getLinuxTargetSubdir() { if (targets && process.platform === 'linux') { if (targets === 'aarch64-unknown-linux-gnu') { return targets; } throw new Error('Invalid or unknown target (only one may be specified)'); } return ''; } function getMacArch() { if (universal) { return 'universal'; } else { // Not specifying an arch makes Electron builder build for the arch it's running on. return undefined; } } // Replace '-' with `~` (tilde) before the beta component, to make the version comparison // understand that stable `YYYY.NN` is newer than beta `YYYY.NN-betaN`. Both Debian and // Fedora do this where a tilde denotes a version component that must be sorted as earlier // than a non-tilde version component // https://docs.fedoraproject.org/en-US/packaging-guidelines/Versioning/#_complex_versioning function getLinuxVersion() { const [version, ...prereleaseParts] = productVersion().split('-'); const [major, minor] = version.split('.'); const prerelease = prereleaseParts.join('-'); if (prerelease) { if (prerelease.toLowerCase().startsWith('beta')) { return `${major}.${minor}~${prerelease}`; } return `${major}.${minor}-${prerelease}`; } return `${major}.${minor}`; } // Returns the product version. function productVersion() { const args = ['run', '-q', '--bin', 'mullvad-version']; return execFileSync('cargo', args, { encoding: 'utf-8' }).trim(); } function rpmChangelogPath() { return buildAssets('rpm-changelog'); } // fpm dates the rpm %changelog entry it generates from the wall clock. Its deb output honors // SOURCE_DATE_EPOCH, its rpm output does not, so two rpms built from the same commit on different // days differ. Write the entry ourselves instead, honoring SOURCE_DATE_EPOCH, to enable // reproducible builds. // Upstream bug report: https://github.com/jordansissel/fpm/issues/2154 function writeRpmChangelog() { const sourceDateEpoch = process.env.SOURCE_DATE_EPOCH; const date = new Date(sourceDateEpoch ? Number(sourceDateEpoch) * 1000 : Date.now()); // rpm expects ' '. toUTCString has a format fixed by the // ECMAScript spec, 'Tue, 14 Nov 2023 22:13:20 GMT', so just reorder its parts. Being UTC also // keeps the time zone of the build machine from shifting the date, which it does for fpm. const [weekday, dayOfMonth, month, year] = date.toUTCString().replace(',', '').split(' '); const formattedDate = `${weekday} ${month} ${dayOfMonth} ${year}`; // The rest mirrors what fpm would have generated: it replaces dashes in the rpm version with // underscores, and defaults the iteration ('Release' in rpm) to 1. const rpmVersion = `${getLinuxVersion().replace(/-/g, '_')}-1`; const maintainer = `${author.name} <${author.email}>`; const changelogPath = rpmChangelogPath(); fs.mkdirSync(path.dirname(changelogPath), { recursive: true }); fs.writeFileSync( changelogPath, `* ${formattedDate} ${maintainer} - ${rpmVersion}\n- Package created with FPM\n`, ); } async function removeNseventforwarderNativeModules() { try { await fs.promises.rm('../../node_modules/nseventforwarder/dist/', { recursive: true }); } catch { // noop } } exports.packWin = packWin; exports.packMac = packMac; exports.packLinux = packLinux;