Skip to content
Merged
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
22 changes: 21 additions & 1 deletion src/icon-composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,27 @@ import path from 'node:path';
import plist from 'plist';
import semver from 'semver';

export async function generateAssetCatalogForIcon(inputPath: string) {
// `actool` is not guaranteed to produce a byte-identical `Assets.car` across
// invocations, so when building a universal app we must compile each `.icon`
// exactly once and reuse the output for every architecture. Otherwise the
// per-arch `Assets.car` files differ and the universal stitch fails. The cache
// is keyed by the resolved input path and lives for the duration of the process,
// which spans both arch slices of a universal build.
const assetCatalogCache = new Map<string, Promise<Buffer>>();

export function generateAssetCatalogForIcon(inputPath: string): Promise<Buffer> {
const cacheKey = path.resolve(inputPath);
let assetCatalog = assetCatalogCache.get(cacheKey);
if (!assetCatalog) {
assetCatalog = compileAssetCatalogForIcon(inputPath);
// Don't cache failures so a transient `actool` error can be retried.
assetCatalog.catch(() => assetCatalogCache.delete(cacheKey));
assetCatalogCache.set(cacheKey, assetCatalog);
}
return assetCatalog;
}

async function compileAssetCatalogForIcon(inputPath: string) {
if (!semver.gte(os.release(), '25.0.0')) {
throw new Error(`actool .icon support is currently limited to macOS 26 and higher`);
}
Expand Down
71 changes: 71 additions & 0 deletions test/icon-composer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const spawn = vi.hoisted(() => vi.fn());

vi.mock('@malept/cross-spawn-promise', () => ({ spawn }));

const VERSION_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.actool.version</key>
<dict>
<key>short-bundle-version</key>
<string>26.0.0</string>
</dict>
</dict>
</plist>`;

describe('generateAssetCatalogForIcon', () => {
beforeEach(() => {
vi.spyOn(os, 'release').mockReturnValue('26.0.0');
spawn.mockImplementation(async (_cmd: string, args: string[]) => {
if (args[0] === '--version') {
return VERSION_PLIST;
}
// The `--compile` invocation: emulate `actool` writing the catalog.
const outputPath = args[args.indexOf('--compile') + 1];
await fs.writeFile(path.resolve(outputPath, 'Assets.car'), 'compiled');
return '';
});
});

afterEach(() => {
vi.restoreAllMocks();
});

it('compiles each unique icon only once and reuses the result', async () => {
// Import lazily with a fresh module so the module-level cache starts empty.
vi.resetModules();
const { generateAssetCatalogForIcon } = await import('../src/icon-composer.js');

const iconA = path.resolve(os.tmpdir(), 'A.icon');
const iconB = path.resolve(os.tmpdir(), 'B.icon');
await fs.mkdir(iconA, { recursive: true });
await fs.mkdir(iconB, { recursive: true });

const compileCalls = () =>
spawn.mock.calls.filter((call) => (call[1] as string[]).includes('--compile')).length;

const [first, second] = await Promise.all([
generateAssetCatalogForIcon(iconA),
generateAssetCatalogForIcon(iconA),
]);
// Same input path -> `actool --compile` runs exactly once.
expect(compileCalls()).toBe(1);
expect(first.equals(second)).toBe(true);

// A relative path resolving to the same icon hits the cache too.
const third = await generateAssetCatalogForIcon(path.relative(process.cwd(), iconA));
expect(compileCalls()).toBe(1);
expect(third.equals(first)).toBe(true);

// A different icon compiles again.
await generateAssetCatalogForIcon(iconB);
expect(compileCalls()).toBe(2);
});
});
Loading