diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts index 4446fcd262..5b35d7955e 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts @@ -2,10 +2,11 @@ import { createHash } from "node:crypto"; import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { encodeNibArchiveFixture } from "./NibArchive.fixture.js"; +import type { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; import { analyzeAppleAssetCatalogs, @@ -206,3 +207,90 @@ it("keeps recognized image fields on NIBArchive controls out of resource joins", }); expect(result.resource_key_matches).toEqual([]); }); + +describe("Apple asset catalog and Interface Builder concurrency", () => { + const catalogBundle = async () => { + const bundle = await createTestTempDirectory("rea-asset-catalog-ib-"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + await writeFile(join(resources, "Assets.car"), "catalog"); + return bundle; + }; + const emptyGraph = { + graph: { nodes: [], truncated: false }, + } as unknown as Awaited>; + + it("keeps the Interface Builder analysis running while catalogs are inspected", async () => { + // The Interface Builder analysis can finish only after catalog inspection + // starts, so a sequential implementation would never complete. + let catalogStarted!: () => void; + const catalogStart = new Promise((resolve) => { + catalogStarted = resolve; + }); + const result = await analyzeAppleAssetCatalogs({ + environment: {}, + bundlePath: await catalogBundle(), + targetSha256: "a".repeat(64), + analyzeInterfaceBuilder: async () => { + await catalogStart; + return emptyGraph; + }, + runAssetUtil: async () => { + catalogStarted(); + return "[]"; + }, + }); + expect(result.catalogs).toHaveLength(1); + }, 5_000); + + it("cancels the Interface Builder analysis and reports a catalog failure", async () => { + let cancelled = false; + let settled = false; + const rejected = await analyzeAppleAssetCatalogs({ + environment: {}, + bundlePath: await catalogBundle(), + targetSha256: "a".repeat(64), + analyzeInterfaceBuilder: ({ signal }) => + new Promise((_, reject) => { + signal?.addEventListener("abort", () => { + cancelled = true; + setImmediate(() => { + settled = true; + reject(new Error("interface builder cancelled")); + }); + }); + }), + runAssetUtil: () => Promise.reject(new Error("assetutil failed")), + }).catch((cause: unknown) => cause); + expect(rejected).toMatchObject({ message: "assetutil failed" }); + expect(cancelled).toBe(true); + expect(settled).toBe(true); + }); + + it("reports an early Interface Builder failure only after catalogs are inspected", async () => { + let finishCatalog!: (output: string) => void; + let catalogStarted = false; + let settled = false; + const analysis = analyzeAppleAssetCatalogs({ + environment: {}, + bundlePath: await catalogBundle(), + targetSha256: "a".repeat(64), + analyzeInterfaceBuilder: () => + Promise.reject(new Error("interface builder failed")), + runAssetUtil: () => { + catalogStarted = true; + return new Promise((resolve) => { + finishCatalog = resolve; + }); + }, + }).finally(() => { + settled = true; + }); + analysis.catch(() => undefined); + await vi.waitFor(() => expect(catalogStarted).toBe(true)); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + finishCatalog("[]"); + await expect(analysis).rejects.toThrow("interface builder failed"); + }); +}); diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts index 5fe1bced32..262f63d70e 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts @@ -29,6 +29,7 @@ export const analyzeAppleAssetCatalogs = async (input: { path: string, signal?: AbortSignal, ) => Promise; + readonly analyzeInterfaceBuilder?: typeof analyzeInterfaceBuilderBundle; }) => { if (process.platform !== "darwin" && input.runAssetUtil === undefined) throw new ArtifactReaderFailure( @@ -36,6 +37,81 @@ export const analyzeAppleAssetCatalogs = async (input: { "Apple asset catalog inspection requires macOS assetutil", ); const page = appleAssetCatalogInputSchema.parse(input.page ?? {}); + // The Interface Builder graph only supplies resource keys for the join. Its + // bundle walk is independent of the catalog walk, so both run together; a + // catalog failure cancels it and is reported as the sequential order would. + const interfaceBuilderCancel = new AbortController(); + const interfaceBuilderAnalysis = ( + input.analyzeInterfaceBuilder ?? analyzeInterfaceBuilderBundle + )({ + bundlePath: input.bundlePath, + targetSha256: input.targetSha256, + signal: + input.signal === undefined + ? interfaceBuilderCancel.signal + : AbortSignal.any([input.signal, interfaceBuilderCancel.signal]), + }).then( + (value) => ({ ok: true, value }) as const, + (error: unknown) => ({ ok: false, error }) as const, + ); + let catalogs: Awaited>["catalogs"]; + let uninspected: readonly string[]; + try { + ({ catalogs, uninspected } = await collectAssetCatalogs(input)); + } catch (cause: unknown) { + interfaceBuilderCancel.abort(); + await interfaceBuilderAnalysis; + throw cause; + } + const analyzed = await interfaceBuilderAnalysis; + if (!analyzed.ok) throw analyzed.error; + const interfaceBuilder = analyzed.value; + const resourceReferences = collectInterfaceBuilderResourceKeys( + interfaceBuilder.graph.nodes, + ); + const result = projectAppleAssetCatalogPage({ + targetSha256: input.targetSha256, + catalogs, + offset: page.offset, + limit: page.limit, + resourceReferences, + }); + return { + ...result, + limitations: [ + ...result.limitations, + // An app without a compiled catalog is an observed empty inventory, not + // a missing host tool. + ...(catalogs.length === 0 + ? [ + "No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.", + ] + : []), + ...(uninspected.length === 0 + ? [] + : [ + `Asset catalog entries that are not regular files were not inspected, so resource keys reported as unmatched may resolve there: ${uninspected.join(", ")}`, + ]), + "Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred.", + ...(interfaceBuilder.graph.truncated + ? [ + "Interface Builder resource-key joins may be incomplete because the decoded UI graph was truncated.", + ] + : []), + ], + }; +}; + +/** Inspect every regular Assets.car in the bundle; non-file entries are listed. */ +const collectAssetCatalogs = async (input: { + readonly bundlePath: string; + readonly environment: Readonly; + readonly signal?: AbortSignal; + readonly runAssetUtil?: ( + path: string, + signal?: AbortSignal, + ) => Promise; +}) => { const reader = new DirectoryArtifactReader(input.bundlePath); const catalogs: { path: string; @@ -77,45 +153,7 @@ export const analyzeAppleAssetCatalogs = async (input: { "path", `Asset catalog entries are not regular files and were not inspected: ${uninspected.join(", ")}`, ); - const interfaceBuilder = await analyzeInterfaceBuilderBundle({ - bundlePath: input.bundlePath, - targetSha256: input.targetSha256, - ...(input.signal === undefined ? {} : { signal: input.signal }), - }); - const resourceReferences = collectInterfaceBuilderResourceKeys( - interfaceBuilder.graph.nodes, - ); - const result = projectAppleAssetCatalogPage({ - targetSha256: input.targetSha256, - catalogs, - offset: page.offset, - limit: page.limit, - resourceReferences, - }); - return { - ...result, - limitations: [ - ...result.limitations, - // An app without a compiled catalog is an observed empty inventory, not - // a missing host tool. - ...(catalogs.length === 0 - ? [ - "No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.", - ] - : []), - ...(uninspected.length === 0 - ? [] - : [ - `Asset catalog entries that are not regular files were not inspected, so resource keys reported as unmatched may resolve there: ${uninspected.join(", ")}`, - ]), - "Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred.", - ...(interfaceBuilder.graph.truncated - ? [ - "Interface Builder resource-key joins may be incomplete because the decoded UI graph was truncated.", - ] - : []), - ], - }; + return { catalogs, uninspected }; }; const inspectAssetCatalogEntry = async (input: {