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;