Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) =>
Expand Down Expand Up @@ -177,6 +187,16 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
// 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`,
);
Expand Down
57 changes: 57 additions & 0 deletions test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down