diff --git a/package.json b/package.json index 6971e7d34..76b4c2103 100644 --- a/package.json +++ b/package.json @@ -333,7 +333,8 @@ "target": "dmg", "arch": [ "x64", - "arm64" + "arm64", + "universal" ] } ], @@ -398,6 +399,7 @@ ] }, "beforeBuild": "scripts/install-cross-deps.js", + "afterPack": "ts/scripts/merge-macos-asars.js", "asarUnpack": [ "ts/workers/heicConverter.bundle.js", "ts/sql/mainWorker.bundle.js", diff --git a/scripts/zip-macos-release.js b/scripts/zip-macos-release.js deleted file mode 100644 index 3cdc4ac50..000000000 --- a/scripts/zip-macos-release.js +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -const { zipMacOSRelease } = require('../ts/scripts/zip-macos-release'); - -zipMacOSRelease(); diff --git a/ts/scripts/merge-macos-asars.ts b/ts/scripts/merge-macos-asars.ts new file mode 100644 index 000000000..2129cf333 --- /dev/null +++ b/ts/scripts/merge-macos-asars.ts @@ -0,0 +1,221 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { existsSync } from 'fs'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import type { AfterPackContext } from 'electron-builder'; +import asar from 'asar'; + +// See: https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary +const LIPO = process.env.LIPO || 'lipo'; + +// See: https://github.com/apple-opensource-mirror/llvmCore/blob/0c60489d96c87140db9a6a14c6e82b15f5e5d252/include/llvm/Object/MachOFormat.h#L108-L112 +// If binary file starts with one of the following magic numbers - it is most +// likely a a Mach-O file or simply a macOS object file. We use this check to +// detect binding files below. +const MACHO_MAGIC = new Set([ + // 32-bit Mach-O + 0xfeedface, 0xcefaedfe, + + // 64-bit Mach-O + 0xfeedfacf, 0xcffaedfe, + + // Universal + 0xcafebabe, 0xbebafeca, +]); + +function toRelativePath(file: string): string { + return file.replace(/^\//, ''); +} + +function isDirectory(a: string, file: string): boolean { + return Boolean('files' in asar.statFile(a, file)); +} + +export async function afterPack(context: AfterPackContext): Promise { + const { appOutDir, packager, electronPlatformName } = context; + if (electronPlatformName !== 'darwin') { + return; + } + + if (!appOutDir.includes('mac-universal')) { + return; + } + + const { productFilename } = packager.appInfo; + const arm64 = appOutDir.replace(/--[^-]*$/, '--arm64'); + const x64 = appOutDir.replace(/--[^-]*$/, '--x64'); + + const commonPath = path.join('Contents', 'Resources', 'app.asar'); + const archive = path.join(arm64, `${productFilename}.app`, commonPath); + const otherArchive = path.join(x64, `${productFilename}.app`, commonPath); + + if (!existsSync(archive)) { + console.info(`${archive} does not exist yet`); + return; + } + if (!existsSync(otherArchive)) { + console.info(`${otherArchive} does not exist yet`); + return; + } + + console.log(`Merging ${archive} and ${otherArchive}`); + + const files = new Set(asar.listPackage(archive).map(toRelativePath)); + const otherFiles = new Set( + asar.listPackage(otherArchive).map(toRelativePath) + ); + + // + // Build set of unpacked directories and files + // + + const unpackedFiles = new Set(); + + function buildUnpacked(a: string, fileList: Set): void { + for (const file of fileList) { + const stat = asar.statFile(a, file); + + if (!('unpacked' in stat) || !stat.unpacked) { + continue; + } + + if ('files' in stat) { + continue; + } + unpackedFiles.add(file); + } + } + + buildUnpacked(archive, files); + buildUnpacked(otherArchive, otherFiles); + + // + // Build list of files/directories unique to each asar + // + + const unique = []; + for (const file of otherFiles) { + if (!files.has(file)) { + unique.push(file); + } + } + + // + // Find files with different content + // + + const bindings = []; + for (const file of files) { + if (!otherFiles.has(file)) { + continue; + } + + // Skip directories + if (isDirectory(archive, file)) { + continue; + } + + const content = asar.extractFile(archive, file); + const otherContent = asar.extractFile(otherArchive, file); + + if (content.compare(otherContent) === 0) { + continue; + } + + if (!MACHO_MAGIC.has(content.readUInt32LE(0))) { + throw new Error(`Can't reconcile two non-macho files ${file}`); + } + + bindings.push(file); + } + + // + // Extract both asars and copy unique directories/files from `otherArchive` + // to extracted `archive`. Then run `lipo` on every shared binding and + // overwrite original ASARs with the new merged ASAR. + // + // The point is - we want electron-builder to find identical ASARs and thus + // include only a single ASAR in the final build. + // + // Once (If) https://github.com/electron/universal/pull/34 lands - we can + // remove this script and start using optimized version of the process + // with a single output ASAR instead of two. + // + + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'archive-')); + const otherDir = await fs.mkdtemp(path.join(os.tmpdir(), 'other-archive-')); + + try { + console.log(`Extracting ${archive} to ${dir}`); + asar.extractAll(archive, dir); + + console.log(`Extracting ${otherArchive} to ${otherDir}`); + asar.extractAll(otherArchive, otherDir); + + for (const file of unique) { + const source = path.resolve(otherDir, file); + const destination = path.resolve(dir, file); + + if (isDirectory(otherArchive, file)) { + console.log(`Creating unique directory: ${file}`); + // eslint-disable-next-line no-await-in-loop + await fs.mkdir(destination, { recursive: true }); + continue; + } + + console.log(`Copying unique file: ${file}`); + // eslint-disable-next-line no-await-in-loop + await fs.mkdir(path.dirname(destination), { recursive: true }); + // eslint-disable-next-line no-await-in-loop + await fs.copyFile(source, destination); + } + + for (const binding of bindings) { + // eslint-disable-next-line no-await-in-loop + const source = await fs.realpath(path.resolve(otherDir, binding)); + // eslint-disable-next-line no-await-in-loop + const destination = await fs.realpath(path.resolve(dir, binding)); + + console.log(`Merging binding: ${binding}`); + execFileSync(LIPO, [ + source, + destination, + '-create', + '-output', + destination, + ]); + } + + for (const dest of [archive, otherArchive]) { + console.log(`Removing ${dest}`); + + // eslint-disable-next-line no-await-in-loop + await Promise.all([ + fs.rm(dest, { recursive: true }), + fs.rm(`${dest}.unpacked`, { recursive: true }), + ]); + + const resolvedUnpack = Array.from(unpackedFiles).map(file => + path.join(dir, file) + ); + + console.log(`Overwriting ${dest}`); + + // eslint-disable-next-line no-await-in-loop + await asar.createPackageWithOptions(dir, dest, { + unpack: `{${resolvedUnpack.join(',')}}`, + }); + } + + console.log('Success'); + } finally { + await Promise.all([ + fs.rm(dir, { recursive: true }), + fs.rm(otherDir, { recursive: true }), + ]); + } +} diff --git a/ts/scripts/zip-macos-release.ts b/ts/scripts/zip-macos-release.ts index 4710d16c4..d5372caf3 100644 --- a/ts/scripts/zip-macos-release.ts +++ b/ts/scripts/zip-macos-release.ts @@ -7,7 +7,7 @@ import rimraf from 'rimraf'; import { execSync } from 'child_process'; import packageJSON from '../../package.json'; -export function zipMacOSRelease(): void { +function zipMacOSRelease(): void { if (process.platform !== 'darwin') { return; } @@ -82,3 +82,5 @@ export function zipMacOSRelease(): void { console.log('zip-macos-release is done'); } + +zipMacOSRelease(); diff --git a/webpack.config.ts b/webpack.config.ts index f6802b493..14a70eb99 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -28,7 +28,6 @@ const csp = ` const stickerCreatorConfig: Configuration = { context, mode: mode as Configuration['mode'], - devtool: 'source-map', entry: [ 'react-hot-loader/patch', 'sanitize.css',