diff --git a/src/icon-composer.ts b/src/icon-composer.ts index 7e101949..96282338 100644 --- a/src/icon-composer.ts +++ b/src/icon-composer.ts @@ -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>(); + +export function generateAssetCatalogForIcon(inputPath: string): Promise { + 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`); } diff --git a/test/icon-composer.spec.ts b/test/icon-composer.spec.ts new file mode 100644 index 00000000..9887d940 --- /dev/null +++ b/test/icon-composer.spec.ts @@ -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 = ` + + + + com.apple.actool.version + + short-bundle-version + 26.0.0 + + +`; + +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); + }); +});