diff --git a/src/asar-utils.ts b/src/asar-utils.ts index 9633051..dcadf8a 100644 --- a/src/asar-utils.ts +++ b/src/asar-utils.ts @@ -84,8 +84,10 @@ export const mergeASARs = async ({ }: MergeASARsOptions): Promise => { d(`merging ${x64AsarPath} and ${arm64AsarPath}`); - const x64Files = new Set(asar.listPackage(x64AsarPath).map(toRelativePath)); - const arm64Files = new Set(asar.listPackage(arm64AsarPath).map(toRelativePath)); + const x64Files = new Set(asar.listPackage(x64AsarPath, { isPack: false }).map(toRelativePath)); + const arm64Files = new Set( + asar.listPackage(arm64AsarPath, { isPack: false }).map(toRelativePath), + ); // // Build set of unpacked directories and files diff --git a/src/file-utils.ts b/src/file-utils.ts index 7a603a8..bea8956 100644 --- a/src/file-utils.ts +++ b/src/file-utils.ts @@ -1,6 +1,13 @@ -import { spawn, ExitCodeError } from '@malept/cross-spawn-promise'; +import * as asar from '@electron/asar'; +import { ExitCodeError, spawn } from '@malept/cross-spawn-promise'; +import * as dircompare from 'dir-compare'; import * as fs from 'fs-extra'; +import { minimatch } from 'minimatch'; import * as path from 'path'; +import { MakeUniversalOpts } from '.'; +import { generateAsarIntegrity } from './asar-utils'; +import { d } from './debug'; +import { sha } from './sha'; const MACHO_PREFIX = 'Mach-O '; @@ -71,3 +78,187 @@ export const getAllAppFiles = async (appPath: string): Promise => { return files; }; + +export async function mergeAppFiles( + tmpApp: string, + opts: MakeUniversalOpts, + knownMergedMachOFiles: Set, + tmpDir: string, +) { + d('checking if the x64 and arm64 app folders are identical'); + const comparison = await dircompare.compare( + path.resolve(tmpApp, 'Contents', 'Resources', 'app'), + path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), + { compareSize: true, compareContent: true }, + ); + const differences = comparison.diffSet!.filter((difference) => difference.state !== 'equal'); + d(`Found ${differences.length} difference(s) between the x64 and arm64 folders`); + const nonMergedDifferences = differences.filter( + (difference) => + !difference.name1 || + !knownMergedMachOFiles.has( + path.join('Contents', 'Resources', 'app', difference.relativePath, difference.name1), + ), + ); + d(`After discluding MachO files merged with lipo ${nonMergedDifferences.length} remain.`); + + if (nonMergedDifferences.length === 0) { + d('x64 and arm64 app folders are the same'); + return; + } + + d('x64 and arm64 app folders are different, creating dynamic entry ASAR'); + await fs.move( + path.resolve(tmpApp, 'Contents', 'Resources', 'app'), + path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64'), + ); + await fs.copy( + path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), + path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64'), + ); + + const entryAsar = path.resolve(tmpDir, 'entry-asar'); + await fs.mkdir(entryAsar); + await fs.copy( + path.resolve(__dirname, '..', '..', 'entry-asar', 'no-asar.js'), + path.resolve(entryAsar, 'index.js'), + ); + let pj = await fs.readJson( + path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'), + ); + pj.main = 'index.js'; + await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); + await asar.createPackage(entryAsar, path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar')); +} + +export async function lipoMachOFiles(x64Files: AppFile[], tmpApp: string, opts: MakeUniversalOpts) { + const knownMergedMachOFiles = new Set(); + for (const machOFile of x64Files.filter((f) => f.type === AppFileType.MACHO)) { + const first = await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)); + const second = await fs.realpath(path.resolve(opts.arm64AppPath, machOFile.relativePath)); + + const x64Sha = await sha(path.resolve(opts.x64AppPath, machOFile.relativePath)); + const arm64Sha = await sha(path.resolve(opts.arm64AppPath, machOFile.relativePath)); + if (x64Sha === arm64Sha) { + if ( + opts.x64ArchFiles === undefined || + !minimatch(machOFile.relativePath, opts.x64ArchFiles, { matchBase: true }) + ) { + throw new Error( + `Detected file "${machOFile.relativePath}" that's the same in both x64 and arm64 builds and not covered by the ` + + `x64ArchFiles rule: "${opts.x64ArchFiles}"`, + ); + } + + d( + 'SHA for Mach-O file', + machOFile.relativePath, + `matches across builds ${x64Sha}===${arm64Sha}, skipping lipo`, + ); + continue; + } + + d('joining two MachO files with lipo', { + first, + second, + }); + await spawn('lipo', [ + first, + second, + '-create', + '-output', + await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)), + ]); + knownMergedMachOFiles.add(machOFile.relativePath); + } + return knownMergedMachOFiles; +} + +export async function copySnapshots( + arm64Files: AppFile[], + opts: MakeUniversalOpts, + tmpApp: string, +) { + for (const snapshotsFile of arm64Files.filter((f) => f.type === AppFileType.SNAPSHOT)) { + d('copying snapshot file', snapshotsFile.relativePath, 'to target application'); + await fs.copy( + path.resolve(opts.arm64AppPath, snapshotsFile.relativePath), + path.resolve(tmpApp, snapshotsFile.relativePath), + ); + } +} + +// returns true if asar is split and shim is added +export async function copyAndShimAsarIfNeeded( + tmpApp: string, + opts: MakeUniversalOpts, + tmpDir: string, + generatedIntegrity: Record, +) { + d('checking if the x64 and arm64 asars are identical'); + const x64AsarSha = await sha(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar')); + const arm64AsarSha = await sha( + path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), + ); + + if (x64AsarSha !== arm64AsarSha) { + d('x64 and arm64 asars are different'); + const x64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar'); + await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), x64AsarPath); + const x64Unpacked = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar.unpacked'); + if (await fs.pathExists(x64Unpacked)) { + await fs.move( + x64Unpacked, + path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar.unpacked'), + ); + } + + const arm64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar'); + await fs.copy( + path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), + arm64AsarPath, + ); + const arm64Unpacked = path.resolve( + opts.arm64AppPath, + 'Contents', + 'Resources', + 'app.asar.unpacked', + ); + if (await fs.pathExists(arm64Unpacked)) { + await fs.copy( + arm64Unpacked, + path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar.unpacked'), + ); + } + + const entryAsar = path.resolve(tmpDir, 'entry-asar'); + await fs.mkdir(entryAsar); + await fs.copy( + path.resolve(__dirname, '..', '..', 'entry-asar', 'has-asar.js'), + path.resolve(entryAsar, 'index.js'), + ); + let pj = JSON.parse( + asar + .extractFile( + path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app.asar'), + 'package.json', + ) + .toString('utf8'), + ); + pj.main = 'index.js'; + await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); + const asarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'); + await asar.createPackage(entryAsar, asarPath); + + generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(asarPath); + generatedIntegrity['Resources/app-x64.asar'] = generateAsarIntegrity(x64AsarPath); + generatedIntegrity['Resources/app-arm64.asar'] = generateAsarIntegrity(arm64AsarPath); + return true; + } + + d('x64 and arm64 asars are the same'); + generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity( + path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), + ); + return false; +} diff --git a/src/index.ts b/src/index.ts index c7492a6..c5fbe30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,21 @@ import { spawn } from '@malept/cross-spawn-promise'; -import * as asar from '@electron/asar'; import * as fs from 'fs-extra'; -import { minimatch } from 'minimatch'; import * as os from 'os'; import * as path from 'path'; -import * as plist from 'plist'; -import * as dircompare from 'dir-compare'; -import { AppFile, AppFileType, getAllAppFiles } from './file-utils'; +import { + AppFile, + AppFileType, + copyAndShimAsarIfNeeded, + copySnapshots, + getAllAppFiles, + lipoMachOFiles, + mergeAppFiles, +} from './file-utils'; import { AsarMode, detectAsarMode, generateAsarIntegrity, mergeASARs } from './asar-utils'; import { sha } from './sha'; import { d } from './debug'; +import { AsarIntegrity, injectAsarIntegrity } from './integrity'; /** * Options to pass into the {@link makeUniversalApp} function. @@ -72,9 +77,6 @@ export type MakeUniversalOpts = { infoPlistsToIgnore?: string; }; -const dupedFiles = (files: AppFile[]) => - files.filter((f) => f.type !== AppFileType.SNAPSHOT && f.type !== AppFileType.APP_CODE); - export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise => { d('making a universal app with options', opts); @@ -117,84 +119,9 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = const tmpApp = path.resolve(tmpDir, 'Tmp.app'); await spawn('cp', ['-R', opts.x64AppPath, tmpApp]); - const uniqueToX64: string[] = []; - const uniqueToArm64: string[] = []; - const x64Files = await getAllAppFiles(await fs.realpath(tmpApp)); - const arm64Files = await getAllAppFiles(await fs.realpath(opts.arm64AppPath)); - - for (const file of dupedFiles(x64Files)) { - if (!arm64Files.some((f) => f.relativePath === file.relativePath)) - uniqueToX64.push(file.relativePath); - } - for (const file of dupedFiles(arm64Files)) { - if (!x64Files.some((f) => f.relativePath === file.relativePath)) - uniqueToArm64.push(file.relativePath); - } - if (uniqueToX64.length !== 0 || uniqueToArm64.length !== 0) { - d('some files were not in both builds, aborting'); - console.error({ - uniqueToX64, - uniqueToArm64, - }); - throw new Error( - 'While trying to merge mach-o files across your apps we found a mismatch, the number of mach-o files is not the same between the arm64 and x64 builds', - ); - } - - for (const file of x64Files.filter((f) => f.type === AppFileType.PLAIN)) { - const x64Sha = await sha(path.resolve(opts.x64AppPath, file.relativePath)); - const arm64Sha = await sha(path.resolve(opts.arm64AppPath, file.relativePath)); - if (x64Sha !== arm64Sha) { - d('SHA for file', file.relativePath, `does not match across builds ${x64Sha}!=${arm64Sha}`); - // The MainMenu.nib files generated by Xcode13 are deterministic in effect but not deterministic in generated sequence - if (path.basename(path.dirname(file.relativePath)) === 'MainMenu.nib') { - // The mismatch here is OK so we just move on to the next one - continue; - } - throw new Error( - `Expected all non-binary files to have identical SHAs when creating a universal build but "${file.relativePath}" did not`, - ); - } - } - const knownMergedMachOFiles = new Set(); - for (const machOFile of x64Files.filter((f) => f.type === AppFileType.MACHO)) { - const first = await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)); - const second = await fs.realpath(path.resolve(opts.arm64AppPath, machOFile.relativePath)); + const { x64Files, arm64Files } = await validateAppFiles(tmpApp, opts); - const x64Sha = await sha(path.resolve(opts.x64AppPath, machOFile.relativePath)); - const arm64Sha = await sha(path.resolve(opts.arm64AppPath, machOFile.relativePath)); - if (x64Sha === arm64Sha) { - if ( - opts.x64ArchFiles === undefined || - !minimatch(machOFile.relativePath, opts.x64ArchFiles, { matchBase: true }) - ) { - throw new Error( - `Detected file "${machOFile.relativePath}" that's the same in both x64 and arm64 builds and not covered by the ` + - `x64ArchFiles rule: "${opts.x64ArchFiles}"`, - ); - } - - d( - 'SHA for Mach-O file', - machOFile.relativePath, - `matches across builds ${x64Sha}===${arm64Sha}, skipping lipo`, - ); - continue; - } - - d('joining two MachO files with lipo', { - first, - second, - }); - await spawn('lipo', [ - first, - second, - '-create', - '-output', - await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)), - ]); - knownMergedMachOFiles.add(machOFile.relativePath); - } + const knownMergedMachOFiles = await lipoMachOFiles(x64Files, tmpApp, opts); /** * If we don't have an ASAR we need to check if the two "app" folders are identical, if @@ -203,56 +130,10 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = * entrypoint to dynamically load the correct app folder */ if (x64AsarMode === AsarMode.NO_ASAR) { - d('checking if the x64 and arm64 app folders are identical'); - const comparison = await dircompare.compare( - path.resolve(tmpApp, 'Contents', 'Resources', 'app'), - path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), - { compareSize: true, compareContent: true }, - ); - const differences = comparison.diffSet!.filter((difference) => difference.state !== 'equal'); - d(`Found ${differences.length} difference(s) between the x64 and arm64 folders`); - const nonMergedDifferences = differences.filter( - (difference) => - !difference.name1 || - !knownMergedMachOFiles.has( - path.join('Contents', 'Resources', 'app', difference.relativePath, difference.name1), - ), - ); - d(`After discluding MachO files merged with lipo ${nonMergedDifferences.length} remain.`); - - if (nonMergedDifferences.length > 0) { - d('x64 and arm64 app folders are different, creating dynamic entry ASAR'); - await fs.move( - path.resolve(tmpApp, 'Contents', 'Resources', 'app'), - path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64'), - ); - await fs.copy( - path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), - path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64'), - ); - - const entryAsar = path.resolve(tmpDir, 'entry-asar'); - await fs.mkdir(entryAsar); - await fs.copy( - path.resolve(__dirname, '..', '..', 'entry-asar', 'no-asar.js'), - path.resolve(entryAsar, 'index.js'), - ); - let pj = await fs.readJson( - path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'), - ); - pj.main = 'index.js'; - await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); - await asar.createPackage( - entryAsar, - path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), - ); - } else { - d('x64 and arm64 app folders are the same'); - } + await mergeAppFiles(tmpApp, opts, knownMergedMachOFiles, tmpDir); } - const generatedIntegrity: Record = {}; - let didSplitAsar = false; + const generatedIntegrity: AsarIntegrity = {}; /** * If we have an ASAR we just need to check if the two "app.asar" files have the same hash, @@ -262,119 +143,26 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = * look at codifying that assumption as actual logic. */ // FIXME: Codify the assumption that app.asar.unpacked only contains native modules - if (x64AsarMode === AsarMode.HAS_ASAR && opts.mergeASARs) { - d('merging x64 and arm64 asars'); - const output = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'); - await mergeASARs({ - x64AsarPath: path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), - arm64AsarPath: path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), - outputAsarPath: output, - singleArchFiles: opts.singleArchFiles, - }); - - generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(output); - } else if (x64AsarMode === AsarMode.HAS_ASAR) { - d('checking if the x64 and arm64 asars are identical'); - const x64AsarSha = await sha(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar')); - const arm64AsarSha = await sha( - path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), - ); - - if (x64AsarSha !== arm64AsarSha) { - didSplitAsar = true; - d('x64 and arm64 asars are different'); - const x64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar'); - await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), x64AsarPath); - const x64Unpacked = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar.unpacked'); - if (await fs.pathExists(x64Unpacked)) { - await fs.move( - x64Unpacked, - path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar.unpacked'), - ); - } - - const arm64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar'); - await fs.copy( - path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), - arm64AsarPath, - ); - const arm64Unpacked = path.resolve( - opts.arm64AppPath, - 'Contents', - 'Resources', - 'app.asar.unpacked', - ); - if (await fs.pathExists(arm64Unpacked)) { - await fs.copy( - arm64Unpacked, - path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar.unpacked'), - ); - } - - const entryAsar = path.resolve(tmpDir, 'entry-asar'); - await fs.mkdir(entryAsar); - await fs.copy( - path.resolve(__dirname, '..', '..', 'entry-asar', 'has-asar.js'), - path.resolve(entryAsar, 'index.js'), - ); - let pj = JSON.parse( - ( - await asar.extractFile( - path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app.asar'), - 'package.json', - ) - ).toString('utf8'), - ); - pj.main = 'index.js'; - await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); - const asarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'); - await asar.createPackage(entryAsar, asarPath); - - generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(asarPath); - generatedIntegrity['Resources/app-x64.asar'] = generateAsarIntegrity(x64AsarPath); - generatedIntegrity['Resources/app-arm64.asar'] = generateAsarIntegrity(arm64AsarPath); + if (x64AsarMode === AsarMode.HAS_ASAR) { + if (opts.mergeASARs) { + d('merging x64 and arm64 asars'); + const output = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'); + await mergeASARs({ + x64AsarPath: path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), + arm64AsarPath: path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), + outputAsarPath: output, + singleArchFiles: opts.singleArchFiles, + }); + + generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(output); } else { - d('x64 and arm64 asars are the same'); - generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity( - path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), - ); + await copyAndShimAsarIfNeeded(tmpApp, opts, tmpDir, generatedIntegrity); } } - const plistFiles = x64Files.filter((f) => f.type === AppFileType.INFO_PLIST); - for (const plistFile of plistFiles) { - const x64PlistPath = path.resolve(opts.x64AppPath, plistFile.relativePath); - const arm64PlistPath = path.resolve(opts.arm64AppPath, plistFile.relativePath); - - const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = plist.parse( - await fs.readFile(x64PlistPath, 'utf8'), - ) as any; - const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = plist.parse( - await fs.readFile(arm64PlistPath, 'utf8'), - ) as any; - if (JSON.stringify(x64Plist) !== JSON.stringify(arm64Plist)) { - throw new Error( - `Expected all Info.plist files to be identical when ignoring integrity when creating a universal build but "${plistFile.relativePath}" was not`, - ); - } + await injectAsarIntegrity(x64Files, opts, generatedIntegrity, tmpApp); - const injectAsarIntegrity = - !opts.infoPlistsToIgnore || - minimatch(plistFile.relativePath, opts.infoPlistsToIgnore, { matchBase: true }); - const mergedPlist = injectAsarIntegrity - ? { ...x64Plist, ElectronAsarIntegrity: generatedIntegrity } - : { ...x64Plist }; - - await fs.writeFile(path.resolve(tmpApp, plistFile.relativePath), plist.build(mergedPlist)); - } - - for (const snapshotsFile of arm64Files.filter((f) => f.type === AppFileType.SNAPSHOT)) { - d('copying snapshot file', snapshotsFile.relativePath, 'to target application'); - await fs.copy( - path.resolve(opts.arm64AppPath, snapshotsFile.relativePath), - path.resolve(tmpApp, snapshotsFile.relativePath), - ); - } + await copySnapshots(arm64Files, opts, tmpApp); d('moving final universal app to target destination'); await fs.mkdirp(path.dirname(opts.outAppPath)); @@ -385,3 +173,49 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = await fs.remove(tmpDir); } }; + +const dupedFiles = (files: AppFile[]) => + files.filter((f) => f.type !== AppFileType.SNAPSHOT && f.type !== AppFileType.APP_CODE); + +async function validateAppFiles(tmpApp: string, opts: MakeUniversalOpts) { + const uniqueToX64: string[] = []; + const uniqueToArm64: string[] = []; + const x64Files = await getAllAppFiles(await fs.realpath(tmpApp)); + const arm64Files = await getAllAppFiles(await fs.realpath(opts.arm64AppPath)); + + for (const file of dupedFiles(x64Files)) { + if (!arm64Files.some((f) => f.relativePath === file.relativePath)) + uniqueToX64.push(file.relativePath); + } + for (const file of dupedFiles(arm64Files)) { + if (!x64Files.some((f) => f.relativePath === file.relativePath)) + uniqueToArm64.push(file.relativePath); + } + if (uniqueToX64.length !== 0 || uniqueToArm64.length !== 0) { + d('some files were not in both builds, aborting'); + console.error({ + uniqueToX64, + uniqueToArm64, + }); + throw new Error( + 'While trying to merge mach-o files across your apps we found a mismatch, the number of mach-o files is not the same between the arm64 and x64 builds', + ); + } + + for (const file of x64Files.filter((f) => f.type === AppFileType.PLAIN)) { + const x64Sha = await sha(path.resolve(opts.x64AppPath, file.relativePath)); + const arm64Sha = await sha(path.resolve(opts.arm64AppPath, file.relativePath)); + if (x64Sha !== arm64Sha) { + d('SHA for file', file.relativePath, `does not match across builds ${x64Sha}!=${arm64Sha}`); + // The MainMenu.nib files generated by Xcode13 are deterministic in effect but not deterministic in generated sequence + if (path.basename(path.dirname(file.relativePath)) === 'MainMenu.nib') { + // The mismatch here is OK so we just move on to the next one + continue; + } + throw new Error( + `Expected all non-binary files to have identical SHAs when creating a universal build but "${file.relativePath}" did not`, + ); + } + } + return { x64Files, arm64Files }; +} diff --git a/src/integrity.ts b/src/integrity.ts new file mode 100644 index 0000000..ac678ae --- /dev/null +++ b/src/integrity.ts @@ -0,0 +1,49 @@ +import * as fs from 'fs-extra'; +import { minimatch } from 'minimatch'; +import path from 'path'; +import * as plist from 'plist'; +import { MakeUniversalOpts } from '.'; +import { AppFile, AppFileType } from './file-utils'; + +export interface HeaderHash { + algorithm: 'SHA256'; + hash: string; +} + +export interface AsarIntegrity { + [key: string]: HeaderHash; +} + +export async function injectAsarIntegrity( + x64Files: AppFile[], + opts: MakeUniversalOpts, + generatedIntegrity: Record, + tmpApp: string, +) { + const plistFiles = x64Files.filter((f) => f.type === AppFileType.INFO_PLIST); + for (const plistFile of plistFiles) { + const x64PlistPath = path.resolve(opts.x64AppPath, plistFile.relativePath); + const arm64PlistPath = path.resolve(opts.arm64AppPath, plistFile.relativePath); + + const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = plist.parse( + await fs.readFile(x64PlistPath, 'utf8'), + ) as any; + const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = plist.parse( + await fs.readFile(arm64PlistPath, 'utf8'), + ) as any; + if (JSON.stringify(x64Plist) !== JSON.stringify(arm64Plist)) { + throw new Error( + `Expected all Info.plist files to be identical when ignoring integrity when creating a universal build but "${plistFile.relativePath}" was not`, + ); + } + + const injectAsarIntegrity = + !opts.infoPlistsToIgnore || + minimatch(plistFile.relativePath, opts.infoPlistsToIgnore, { matchBase: true }); + const mergedPlist = injectAsarIntegrity + ? { ...x64Plist, ElectronAsarIntegrity: generatedIntegrity } + : { ...x64Plist }; + + await fs.writeFile(path.resolve(tmpApp, plistFile.relativePath), plist.build(mergedPlist)); + } +}