diff --git a/src/index.ts b/src/index.ts index b4c8d3f..b54f7a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,6 +81,16 @@ export type MakeUniversalOpts = { * Use this if your application contains another bundle that's already signed. */ infoPlistsToIgnore?: string; + /** + * A {@link https://github.com/isaacs/minimatch?tab=readme-ov-file#features | minimatch} + * pattern of non-binary file paths that are allowed to differ between the x64 and arm64 + * builds. When a match differs, the x64 copy is kept in the universal app. + * + * Use this for files that are deterministic in effect but not byte-reproducible across + * builds, such as an `Assets.car` asset catalog compiled by `actool` from a macOS 26 + * Icon Composer `.icon` file. + */ + nonBinaryFilesToIgnore?: string; }; const dupedFiles = (files: AppFile[]) => @@ -177,6 +187,16 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = // The mismatch here is OK so we just move on to the next one continue; } + if (opts.nonBinaryFilesToIgnore && matchGlob(file.relativePath, opts.nonBinaryFilesToIgnore)) { + // The file is deterministic in effect but not byte-reproducible (e.g. an + // actool-compiled Assets.car); keep the x64 copy and move on. + d( + 'non-binary file', + file.relativePath, + 'differs across builds but is allowlisted via nonBinaryFilesToIgnore, keeping the x64 copy', + ); + continue; + } throw new Error( `Expected all non-binary files to have identical SHAs when creating a universal build but "${file.relativePath}" did not`, ); diff --git a/test/index.spec.ts b/test/index.spec.ts index 0ce3fcc..f508102 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -60,6 +60,63 @@ describe.concurrent('makeUniversalApp', () => { await verifyApp(expect, out, true); }); + describe('nonBinaryFilesToIgnore', () => { + const templateWithCatalog = (name: string, arch: 'x64' | 'arm64', catalog: string) => + templateApp(name, arch, async (appPath) => { + const { testPath } = await createStagingAppDir(name); + await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar')); + await fs.promises.writeFile( + path.resolve(appPath, 'Contents', 'Resources', 'Assets.car'), + catalog, + ); + }); + + it( + 'throws when a non-binary file differs across arches and is not allowlisted', + { timeout: VERIFY_APP_TIMEOUT }, + async ({ expect }) => { + const x64AppPath = await templateWithCatalog('NonBinaryX64.app', 'x64', 'x64-catalog'); + const arm64AppPath = await templateWithCatalog( + 'NonBinaryArm64.app', + 'arm64', + 'arm64-catalog', + ); + const out = path.resolve(await mkOutDir(), 'NonBinaryError.app'); + await expect( + makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath: out, mergeASARs: true }), + ).rejects.toThrow( + /Expected all non-binary files to have identical SHAs when creating a universal build but "Contents\/Resources\/Assets\.car" did not/, + ); + }, + ); + + it( + 'keeps the x64 copy of a differing non-binary file matched by `nonBinaryFilesToIgnore`', + { timeout: VERIFY_APP_TIMEOUT }, + async ({ expect }) => { + const x64AppPath = await templateWithCatalog('NonBinaryOkX64.app', 'x64', 'x64-catalog'); + const arm64AppPath = await templateWithCatalog( + 'NonBinaryOkArm64.app', + 'arm64', + 'arm64-catalog', + ); + const out = path.resolve(await mkOutDir(), 'NonBinaryOk.app'); + await makeUniversalApp({ + x64AppPath, + arm64AppPath, + outAppPath: out, + mergeASARs: true, + nonBinaryFilesToIgnore: 'Assets.car', + }); + const merged = await fs.promises.readFile( + path.resolve(out, 'Contents', 'Resources', 'Assets.car'), + 'utf-8', + ); + expect(merged).toBe('x64-catalog'); + }, + ); + }); + describe('force', () => { it('throws an error if `out` bundle already exists and `force` is `false`', async ({ expect,