From b0ab9039a7f1b8c7164d8d8d28d1e7516f152be2 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Tue, 25 Aug 2026 08:14:24 +0000 Subject: [PATCH 1/7] feat(build): parse iOS Assets.car into a per-asset size manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse iOS `Assets.car` asset catalogs into a per-rendition size manifest (`ParsedAssets/.../Assets.json`) during `build upload`, so preprod size analysis gets a per-asset breakdown instead of only the raw `.car`. The legacy CLI needed native macOS CoreUI to decode pixels, which gated iOS upload to Apple Silicon. Instead we read the `.car` BOM container in pure TypeScript to enumerate each rendition's size and geometry — no native dependency, works on every platform. The raw `.car` is still uploaded alongside the manifest; pixel extraction remains out of scope. Fixes #1429 --- apps/cli-docs/src/fragments/commands/build.md | 9 +- packages/cli/src/commands/build/upload.ts | 5 +- packages/cli/src/lib/build/asset-catalog.ts | 237 ++++++++++++++++++ packages/cli/src/lib/build/index.ts | 82 +++++- .../cli/test/lib/build/asset-catalog.test.ts | 69 +++++ packages/cli/test/lib/build/car-fixture.ts | 114 +++++++++ packages/cli/test/lib/build/index.test.ts | 64 ++++- 7 files changed, 564 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/lib/build/asset-catalog.ts create mode 100644 packages/cli/test/lib/build/asset-catalog.test.ts create mode 100644 packages/cli/test/lib/build/car-fixture.ts diff --git a/apps/cli-docs/src/fragments/commands/build.md b/apps/cli-docs/src/fragments/commands/build.md index ffa76e9143..693e9cd868 100644 --- a/apps/cli-docs/src/fragments/commands/build.md +++ b/apps/cli-docs/src/fragments/commands/build.md @@ -34,10 +34,11 @@ sentry build download 1234567890 --json - `build upload` supports **Android APK/AAB** and **iOS XCArchive/IPA**. An XCArchive is a directory; an IPA is converted to an XCArchive layout for upload. **Sentry SaaS only.** -- iOS caveat: `Assets.car` asset catalogs are **not** parsed into per-asset - images (that required native macOS frameworks), so the server sees the raw - `.car` rather than a per-image breakdown. XCArchive symlinks and Unix file - permissions are preserved. +- iOS `Assets.car` asset catalogs are parsed into a per-rendition size manifest + (`ParsedAssets/.../Assets.json`) so the server gets a per-asset breakdown; the + raw `.car` is still uploaded alongside it. Pixel extraction (which needs + native macOS frameworks) is **not** performed. XCArchive symlinks and Unix + file permissions are preserved. - Multiple paths may be uploaded at once; the command exits non-zero if any build fails to upload. - Git metadata (commit, branch, PR number, repo) is **auto-collected in CI** diff --git a/packages/cli/src/commands/build/upload.ts b/packages/cli/src/commands/build/upload.ts index 170a296d9f..e447a08495 100644 --- a/packages/cli/src/commands/build/upload.ts +++ b/packages/cli/src/commands/build/upload.ts @@ -155,8 +155,9 @@ export const uploadCommand = buildCommand({ "is normalized into a deterministic ZIP and uploaded via the " + "chunk-upload + assemble protocol.\n\n" + "Supported formats: Android APK/AAB, iOS XCArchive (a directory) and IPA. " + - "Note: iOS Assets.car asset catalogs are not parsed into per-asset " + - "images. This feature only works with Sentry SaaS.\n\n" + + "iOS Assets.car asset catalogs are parsed into a per-asset size manifest " + + "(ParsedAssets/.../Assets.json); pixel extraction still requires macOS " + + "and is not performed. This feature only works with Sentry SaaS.\n\n" + "Usage:\n" + " sentry build upload ./app-release.apk\n" + " sentry build upload ./MyApp.xcarchive\n" + diff --git a/packages/cli/src/lib/build/asset-catalog.ts b/packages/cli/src/lib/build/asset-catalog.ts new file mode 100644 index 0000000000..2505754aac --- /dev/null +++ b/packages/cli/src/lib/build/asset-catalog.ts @@ -0,0 +1,237 @@ +/** + * Pure-TypeScript parser for iOS `Assets.car` asset catalogs. + * + * A `.car` file is a CoreUI asset catalog stored in Apple's BOM ("Bill of + * Materials") container format. The legacy Rust CLI expanded it into per-asset + * images by linking against private macOS CoreUI frameworks, which gated iOS + * upload to Apple Silicon. The new CLI ships on all platforms, so instead of + * decoding pixels we parse the BOM container directly to enumerate each + * rendition and its on-disk size and geometry. That per-asset breakdown is what + * preprod size analysis needs; actual image extraction (which still requires + * CoreUI) remains out of scope. + * + * Format references: the BOM header/block-table/vars layout and the CoreUI + * `RENDITIONS` B-tree plus the `CTSI` rendition header. Only the fields needed + * for a size breakdown are read; unknown regions are skipped by offset. + */ + +/** One parsed rendition from an asset catalog. */ +export type AssetCatalogEntry = { + /** Rendition name from the CSI header (e.g. `"AppIcon"`). */ + name: string; + /** On-disk size in bytes of the rendition's stored value blob. */ + size: number; + /** Whether the rendition is a vector (PDF/SVG) asset. */ + vector: boolean; + /** Pixel width, when present in the CSI header. */ + width: number | null; + /** Pixel height, when present in the CSI header. */ + height: number | null; + /** Scale factor (1, 2, 3), when present in the CSI header. */ + scale: number | null; +}; + +/** BOM container magic (`"BOMStore"`). */ +const BOM_MAGIC = "BOMStore"; + +/** CSI rendition-header magic bytes (`"CTSI"`). */ +const CSI_MAGIC = [0x43, 0x54, 0x53, 0x49]; + +/** + * Byte offsets of the fields we read from a `CTSI` rendition header. Unlike the + * enclosing BOM container (big-endian), the CSI header stores its integers + * little-endian. The fixed header runs `name` (40) + `nameLength` (128) = 168 + * bytes, which is the minimum a value blob must have to be a valid rendition. + */ +const CSI = { + width: 12, + height: 16, + scaleFactor: 20, + pixelFormat: 24, + name: 40, + nameLength: 128, + headerLength: 168, +} as const; + +/** Whether a value blob begins with the `CTSI` rendition-header magic. */ +function hasCsiMagic(buf: Uint8Array, address: number): boolean { + return CSI_MAGIC.every((byte, i) => buf[address + i] === byte); +} + +/** FourCC pixel-format codes for vector (non-raster) renditions. */ +const VECTOR_PIXEL_FORMATS = new Set(["PDF ", "SVG "]); + +/** A block-table pointer into the BOM file: byte address and length. */ +type BomPointer = { address: number; length: number }; + +/** + * Read and validate the BOM block table, returning the block pointers indexed + * by block id (index 0 is the reserved null block). + */ +function readBlockTable(view: DataView, buf: Uint8Array): BomPointer[] { + const indexOffset = view.getUint32(16); + const count = view.getUint32(indexOffset); + const pointers: BomPointer[] = []; + let cursor = indexOffset + 4; + for (let i = 0; i < count; i++) { + if (cursor + 8 > buf.length) { + throw new Error("BOM block table is truncated"); + } + pointers.push({ + address: view.getUint32(cursor), + length: view.getUint32(cursor + 4), + }); + cursor += 8; + } + return pointers; +} + +/** Map each named BOM variable to the block id it points at. */ +function readVars(view: DataView, buf: Uint8Array): Map { + const varsOffset = view.getUint32(24); + const count = view.getUint32(varsOffset); + const vars = new Map(); + let cursor = varsOffset + 4; + const decoder = new TextDecoder("utf-8"); + for (let i = 0; i < count; i++) { + const index = view.getUint32(cursor); + const nameLength = view.getUint8(cursor + 4); + const nameStart = cursor + 5; + if (nameStart + nameLength > buf.length) { + throw new Error("BOM vars table is truncated"); + } + const name = decoder.decode(buf.subarray(nameStart, nameStart + nameLength)); + vars.set(name, index); + cursor = nameStart + nameLength; + } + return vars; +} + +/** A leaf entry of the CoreUI `RENDITIONS` tree: pointers to key and value. */ +type TreeLeaf = { keyIndex: number; valueIndex: number }; + +/** + * Walk a BOM B-tree from its root var block, collecting every leaf entry. + * + * Branch nodes are descended recursively; leaf nodes yield `(key, value)` block + * pointer pairs. A visited set guards against cyclic or self-referential blocks + * in a malformed catalog. + */ +function collectTreeLeaves( + view: DataView, + blocks: BomPointer[], + treeBlockId: number +): TreeLeaf[] { + const tree = blocks[treeBlockId]; + if (!tree) { + return []; + } + const rootNodeId = view.getUint32(tree.address + 8); + const leaves: TreeLeaf[] = []; + const visited = new Set(); + + const walk = (nodeId: number): void => { + if (visited.has(nodeId)) { + return; + } + visited.add(nodeId); + const node = blocks[nodeId]; + if (!node) { + return; + } + // BOMPaths: isLeaf (u16), count (u16), forward (u32), backward (u32), then + // the index entries. The forward/backward sibling links are skipped. + const isLeaf = view.getUint16(node.address); + const count = view.getUint16(node.address + 2); + let cursor = node.address + 12; + for (let i = 0; i < count; i++) { + const valueIndex = view.getUint32(cursor); + const keyIndex = view.getUint32(cursor + 4); + cursor += 8; + if (isLeaf) { + leaves.push({ keyIndex, valueIndex }); + } else { + walk(valueIndex); + } + } + }; + + walk(rootNodeId); + return leaves; +} + +/** Read the NUL-terminated rendition name from a `CTSI` value blob. */ +function readRenditionName(buf: Uint8Array, start: number): string { + const nameStart = start + CSI.name; + const nameEnd = nameStart + CSI.nameLength; + const slice = buf.subarray(nameStart, Math.min(nameEnd, buf.length)); + const terminator = slice.indexOf(0); + const bytes = terminator === -1 ? slice : slice.subarray(0, terminator); + return new TextDecoder("utf-8").decode(bytes); +} + +/** + * Parse an `Assets.car` asset catalog into per-rendition size metadata. + * + * Returns one {@link AssetCatalogEntry} per rendition, sorted by name for a + * deterministic manifest. Throws if the bytes are not a recognizable BOM + * container; callers treat parse failures as non-fatal and fall back to + * uploading the raw `.car`. + * + * @param content - The raw `.car` file bytes. + */ +export function parseAssetCatalog(content: Uint8Array): AssetCatalogEntry[] { + if (content.length < 32) { + throw new Error("File is too small to be an asset catalog"); + } + const magic = new TextDecoder("latin1").decode(content.subarray(0, 8)); + if (magic !== BOM_MAGIC) { + throw new Error("Not a BOM asset catalog (bad magic)"); + } + + const view = new DataView( + content.buffer, + content.byteOffset, + content.byteLength + ); + const blocks = readBlockTable(view, content); + const vars = readVars(view, content); + + const renditionsBlockId = vars.get("RENDITIONS"); + if (renditionsBlockId === undefined) { + return []; + } + + const entries: AssetCatalogEntry[] = []; + for (const leaf of collectTreeLeaves(view, blocks, renditionsBlockId)) { + const value = blocks[leaf.valueIndex]; + if (!value || value.length < CSI.headerLength) { + continue; + } + if (!hasCsiMagic(content, value.address)) { + continue; + } + // CSI header integers are little-endian (the BOM container is big-endian); + // the pixel format is a four-character code stored in file order. + const pfStart = value.address + CSI.pixelFormat; + const pixelFormat = new TextDecoder("latin1").decode( + content.subarray(pfStart, pfStart + 4) + ); + const width = view.getUint32(value.address + CSI.width, true); + const height = view.getUint32(value.address + CSI.height, true); + const scaleFactor = view.getUint32(value.address + CSI.scaleFactor, true); + entries.push({ + name: readRenditionName(content, value.address), + size: value.length, + vector: VECTOR_PIXEL_FORMATS.has(pixelFormat), + width: width > 0 ? width : null, + height: height > 0 ? height : null, + scale: scaleFactor > 0 ? Math.round(scaleFactor / 100) : null, + }); + } + + entries.sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : a.size - b.size + ); + return entries; +} diff --git a/packages/cli/src/lib/build/index.ts b/packages/cli/src/lib/build/index.ts index 85359f2e8e..5c6c6d4936 100644 --- a/packages/cli/src/lib/build/index.ts +++ b/packages/cli/src/lib/build/index.ts @@ -19,8 +19,10 @@ * * Handles Android APK/AAB (file wrappers) and iOS XCArchive (directory) / IPA * (converted to an XCArchive layout). Unlike the legacy CLI, iOS is not gated to - * Apple Silicon — the only native dependency was `Assets.car` parsing, which is - * intentionally skipped (see `normalizeBuildDirectory`). + * Apple Silicon: `Assets.car` catalogs are parsed into a per-rendition size + * manifest with a pure-TypeScript BOM reader (see `normalizeBuildDirectory` and + * `parseAssetCatalog`) rather than the native macOS CoreUI path, which the + * legacy CLI needed to decode pixels. */ import { existsSync, readdirSync, statSync } from "node:fs"; @@ -30,9 +32,52 @@ import { strToU8, unzipSync, type Zippable, zipSync } from "fflate"; import { CLI_VERSION } from "../constants.js"; import { ValidationError } from "../errors.js"; import { logger } from "../logger.js"; +import { type AssetCatalogEntry, parseAssetCatalog } from "./asset-catalog.js"; const log = logger.withTag("build.normalize"); +/** Directory prefix under which parsed asset-catalog manifests are stored. */ +const PARSED_ASSETS_DIR = "ParsedAssets"; + +/** Filename of the per-catalog manifest emitted next to each `Assets.car`. */ +const ASSET_CATALOG_MANIFEST = "Assets.json"; + +/** Whether an archive-relative path is an `Assets.car` asset catalog. */ +function isAssetCatalogPath(relPath: string): boolean { + return relPath === "Assets.car" || relPath.endsWith("/Assets.car"); +} + +/** The ParsedAssets manifest path for an `Assets.car` at `carRelPath`. */ +function manifestPathFor(archiveRoot: string, carRelPath: string): string { + const slash = carRelPath.lastIndexOf("/"); + const dir = slash === -1 ? "" : carRelPath.slice(0, slash); + const prefix = dir ? `${dir}/` : ""; + return `${archiveRoot}/${PARSED_ASSETS_DIR}/${prefix}${ASSET_CATALOG_MANIFEST}`; +} + +/** Serialize an asset-catalog manifest to deterministic JSON bytes. */ +function manifestBytes(assets: AssetCatalogEntry[]): Uint8Array { + return strToU8(`${JSON.stringify({ assets }, null, 2)}\n`); +} + +/** + * Parse an `Assets.car` into a manifest, returning `null` if it can't be read. + * + * The `.car` format is only loosely documented, so a parse failure on an + * unusual catalog is expected; callers fall back to shipping the raw `.car`. + */ +function tryParseAssetCatalog( + carRelPath: string, + content: Uint8Array +): AssetCatalogEntry[] | null { + try { + return parseAssetCatalog(content); + } catch (err) { + log.debug(`Failed to parse asset catalog ${carRelPath}`, err); + return null; + } +} + /** A recognized mobile build format. */ export type BuildFormat = "apk" | "aab" | "ipa" | "xcarchive"; @@ -310,9 +355,12 @@ async function collectArchiveEntries(root: string): Promise { * Symlinks and Unix permissions are preserved (see {@link collectArchiveEntries}); * validate the directory first with {@link validateXcarchiveDirectory}. * - * Documented gap: `Assets.car` asset catalogs are not parsed into per-asset - * images (that required native macOS frameworks), so no `ParsedAssets/` tree is - * added — the raw `.car` is uploaded as-is. + * Each `Assets.car` asset catalog is additionally parsed into a per-rendition + * size manifest written under `/ParsedAssets//Assets.json` (the + * raw `.car` is still uploaded as-is). Unlike the legacy CLI this does not + * decode pixels — that needed native macOS frameworks — but it gives preprod + * size analysis the per-asset breakdown it needs on every platform. See + * {@link parseAssetCatalog}. * * The whole directory is read into memory; a very large XCArchive (e.g. with * dSYMs) could exceed Node's ~2 GiB Buffer cap — streaming is a follow-up. @@ -334,6 +382,15 @@ export async function normalizeBuildDirectory( entry.content, { level: 0, mtime: FIXED_MTIME, os: ZIP_OS_UNIX, attrs: entry.attrs }, ]; + if (isAssetCatalogPath(entry.relPath)) { + const assets = tryParseAssetCatalog(entry.relPath, entry.content); + if (assets) { + entries[manifestPathFor(dirName, entry.relPath)] = [ + manifestBytes(assets), + ENTRY_OPTIONS, + ]; + } + } } entries[METADATA_FILENAME] = [ strToU8(buildMetadataFile(plugin)), @@ -425,10 +482,17 @@ export function normalizeIpa( if (stripped.split("/").includes("..")) { continue; } - archiveEntries.push([ - `${archiveDir}/Products/Applications/${stripped}`, - bytes, - ]); + const productRelPath = `Products/Applications/${stripped}`; + archiveEntries.push([`${archiveDir}/${productRelPath}`, bytes]); + if (isAssetCatalogPath(productRelPath)) { + const assets = tryParseAssetCatalog(productRelPath, bytes); + if (assets) { + archiveEntries.push([ + manifestPathFor(archiveDir, productRelPath), + manifestBytes(assets), + ]); + } + } } archiveEntries.push([ `${archiveDir}/Info.plist`, diff --git a/packages/cli/test/lib/build/asset-catalog.test.ts b/packages/cli/test/lib/build/asset-catalog.test.ts new file mode 100644 index 0000000000..7696aae783 --- /dev/null +++ b/packages/cli/test/lib/build/asset-catalog.test.ts @@ -0,0 +1,69 @@ +/** + * Tests for the pure-TypeScript `Assets.car` (BOM asset catalog) parser. + * + * Fixtures are built in-memory with {@link buildFakeCar}, which assembles a + * minimal but real BOM container so the parser runs end to end without a + * committed binary. + */ + +import { describe, expect, test } from "vitest"; +import { + type AssetCatalogEntry, + parseAssetCatalog, +} from "../../../src/lib/build/asset-catalog.js"; +import { buildFakeCar, type FakeRendition } from "./car-fixture.js"; + +const APP_ICON: FakeRendition = { + name: "AppIcon", + width: 120, + height: 120, + scale: 2, + pixelFormat: "ARGB", + payload: 50, +}; + +const VECTOR_ASSET: FakeRendition = { + name: "Logo", + width: 0, + height: 0, + scale: 1, + pixelFormat: "PDF ", + payload: 200, +}; + +describe("parseAssetCatalog", () => { + test("parses renditions with size and geometry", () => { + const assets = parseAssetCatalog(buildFakeCar([APP_ICON])); + expect(assets).toHaveLength(1); + const entry = assets[0] as AssetCatalogEntry; + expect(entry.name).toBe("AppIcon"); + expect(entry.width).toBe(120); + expect(entry.height).toBe(120); + expect(entry.scale).toBe(2); + expect(entry.vector).toBe(false); + expect(entry.size).toBe(168 + APP_ICON.payload); + }); + + test("flags vector renditions and nulls absent geometry", () => { + const assets = parseAssetCatalog(buildFakeCar([VECTOR_ASSET])); + const entry = assets[0] as AssetCatalogEntry; + expect(entry.vector).toBe(true); + expect(entry.width).toBeNull(); + expect(entry.height).toBeNull(); + }); + + test("returns entries sorted by name", () => { + const assets = parseAssetCatalog(buildFakeCar([APP_ICON, VECTOR_ASSET])); + expect(assets.map((a) => a.name)).toEqual(["AppIcon", "Logo"]); + }); + + test("throws on non-BOM bytes", () => { + expect(() => parseAssetCatalog(new TextEncoder().encode("carbytes"))).toThrow( + /BOM|too small/ + ); + }); + + test("throws on a truncated file", () => { + expect(() => parseAssetCatalog(new Uint8Array(4))).toThrow("too small"); + }); +}); diff --git a/packages/cli/test/lib/build/car-fixture.ts b/packages/cli/test/lib/build/car-fixture.ts new file mode 100644 index 0000000000..5cd72531db --- /dev/null +++ b/packages/cli/test/lib/build/car-fixture.ts @@ -0,0 +1,114 @@ +/** + * In-memory builder for a minimal but real `Assets.car` (BOM asset catalog), + * shared by the parser and normalization tests so neither commits a binary. + * + * It assembles the pieces the parser reads: a 32-byte BOM header, a block + * table, a vars table naming `RENDITIONS`, and a single-leaf B-tree whose leaves + * point at little-endian CSI rendition value blobs. + */ + +/** A rendition to embed in a synthetic catalog. */ +export type FakeRendition = { + name: string; + width: number; + height: number; + scale: number; + pixelFormat: string; + /** Extra padding bytes appended after the fixed CSI header. */ + payload: number; +}; + +/** Byte length of the fixed CSI rendition header. */ +const CSI_HEADER_LENGTH = 168; + +/** Build a little-endian CSI rendition value blob. */ +function buildCsi(r: FakeRendition): Uint8Array { + const buf = new Uint8Array(CSI_HEADER_LENGTH + r.payload); + const view = new DataView(buf.buffer); + buf.set([0x43, 0x54, 0x53, 0x49], 0); // "CTSI" + view.setUint32(12, r.width, true); + view.setUint32(16, r.height, true); + view.setUint32(20, r.scale * 100, true); + buf.set(new TextEncoder().encode(r.pixelFormat), 24); + buf.set(new TextEncoder().encode(r.name), 40); // NUL-padded by default + return buf; +} + +/** Assemble a minimal valid BOM asset catalog containing `renditions`. */ +export function buildFakeCar(renditions: FakeRendition[]): Uint8Array { + const blocks: Uint8Array[] = [new Uint8Array(0)]; // index 0 = null block + + const csiIndices: number[] = []; + const keyIndices: number[] = []; + for (const r of renditions) { + keyIndices.push(blocks.push(new Uint8Array([0, 0])) - 1); + csiIndices.push(blocks.push(buildCsi(r)) - 1); + } + + // Leaf node: isLeaf=1, count, forward=0, backward=0, then (value,key) pairs. + const leaf = new Uint8Array(12 + renditions.length * 8); + const leafView = new DataView(leaf.buffer); + leafView.setUint16(0, 1); // isLeaf + leafView.setUint16(2, renditions.length); // count + for (let i = 0; i < renditions.length; i++) { + leafView.setUint32(12 + i * 8, csiIndices[i] as number); + leafView.setUint32(16 + i * 8, keyIndices[i] as number); + } + const leafIndex = blocks.push(leaf) - 1; + + // Tree block: "tree", version, child(=leaf), then trailing fields. + const tree = new Uint8Array(21); + tree.set(new TextEncoder().encode("tree"), 0); + new DataView(tree.buffer).setUint32(8, leafIndex); + const treeIndex = blocks.push(tree) - 1; + + // Pack block bodies after the 32-byte header, recording addresses. + let cursor = 32; + const addresses: number[] = []; + for (let i = 0; i < blocks.length; i++) { + const bytes = blocks[i] as Uint8Array; + addresses[i] = bytes.length === 0 ? 0 : cursor; + cursor += bytes.length; + } + + // Block table: count, then (address, length) per block. + const blockTable = new Uint8Array(4 + blocks.length * 8); + const btView = new DataView(blockTable.buffer); + btView.setUint32(0, blocks.length); + for (let i = 0; i < blocks.length; i++) { + btView.setUint32(4 + i * 8, addresses[i] as number); + btView.setUint32(8 + i * 8, (blocks[i] as Uint8Array).length); + } + const blockTableOffset = cursor; + cursor += blockTable.length; + + // Vars table: count, then (index, nameLength, name) — just "RENDITIONS". + const varName = "RENDITIONS"; + const vars = new Uint8Array(4 + 5 + varName.length); + const vView = new DataView(vars.buffer); + vView.setUint32(0, 1); + vView.setUint32(4, treeIndex); + vars[8] = varName.length; + vars.set(new TextEncoder().encode(varName), 9); + const varsOffset = cursor; + cursor += vars.length; + + const out = new Uint8Array(cursor); + const outView = new DataView(out.buffer); + out.set(new TextEncoder().encode("BOMStore"), 0); + outView.setUint32(8, 1); // version + outView.setUint32(12, blocks.length); + outView.setUint32(16, blockTableOffset); + outView.setUint32(20, blockTable.length); + outView.setUint32(24, varsOffset); + outView.setUint32(28, vars.length); + for (let i = 0; i < blocks.length; i++) { + const bytes = blocks[i] as Uint8Array; + if (bytes.length > 0) { + out.set(bytes, addresses[i] as number); + } + } + out.set(blockTable, blockTableOffset); + out.set(vars, varsOffset); + return out; +} diff --git a/packages/cli/test/lib/build/index.test.ts b/packages/cli/test/lib/build/index.test.ts index a9696c5a7a..e47c6ee507 100644 --- a/packages/cli/test/lib/build/index.test.ts +++ b/packages/cli/test/lib/build/index.test.ts @@ -16,6 +16,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { strToU8, unzipSync, zipSync } from "fflate"; import { afterEach, describe, expect, test } from "vitest"; +import { buildFakeCar } from "./car-fixture.js"; import { detectBuildFormat, extractIpaAppName, @@ -171,6 +172,49 @@ describe("normalizeBuildDirectory", () => { expect(a.equals(b)).toBe(true); }); + test("emits a ParsedAssets manifest next to a parseable Assets.car", async () => { + const xc = fakeXcarchive(); + const car = buildFakeCar([ + { name: "AppIcon", width: 120, height: 120, scale: 2, pixelFormat: "ARGB", payload: 32 }, + ]); + writeFileSync( + join(xc, "Products", "Applications", "MyApp.app", "Assets.car"), + car + ); + + const entries = unzipSync(await normalizeBuildDirectory(xc, null)); + // The raw .car is still present, and the manifest sits under ParsedAssets/. + expect( + entries["MyApp.xcarchive/Products/Applications/MyApp.app/Assets.car"] + ).toBeDefined(); + const manifestKey = + "MyApp.xcarchive/ParsedAssets/Products/Applications/MyApp.app/Assets.json"; + expect(entries[manifestKey]).toBeDefined(); + const manifest = JSON.parse( + new TextDecoder().decode(entries[manifestKey]) + ); + expect(manifest.assets).toHaveLength(1); + expect(manifest.assets[0]).toMatchObject({ + name: "AppIcon", + width: 120, + height: 120, + scale: 2, + vector: false, + }); + }); + + test("carries an unparseable Assets.car through without a manifest", async () => { + const xc = fakeXcarchive(); + writeFileSync( + join(xc, "Products", "Applications", "MyApp.app", "Assets.car"), + "not-a-real-car" + ); + const entries = unzipSync(await normalizeBuildDirectory(xc, null)); + expect( + Object.keys(entries).some((n) => n.includes("ParsedAssets")) + ).toBe(false); + }); + // Symlinks require privileges on Windows; the unit suite runs on Linux. test.skipIf(process.platform === "win32")( "preserves symlinks as entries (stores the target path, not followed content)", @@ -297,10 +341,14 @@ describe("normalizeIpa", () => { entries["archive.xcarchive/Info.plist"] ); expect(plist).toContain("Applications/MyApp.app"); - // Assets.car is carried through verbatim (not parsed). + // The raw Assets.car is always carried through; a manifest is added only + // when it parses (these fixture bytes are not a real BOM catalog). expect( entries["archive.xcarchive/Products/Applications/MyApp.app/Assets.car"] ).toEqual(strToU8("carbytes")); + expect( + Object.keys(entries).some((n) => n.includes("ParsedAssets")) + ).toBe(false); }); test("remaps nested framework entries under the app", () => { @@ -333,6 +381,20 @@ describe("normalizeIpa", () => { ).toBe(true); }); + test("emits a ParsedAssets manifest for a parseable Assets.car", () => { + const ipa = zipSync({ + "Payload/MyApp.app/Info.plist": strToU8(""), + "Payload/MyApp.app/Assets.car": buildFakeCar([ + { name: "Logo", width: 0, height: 0, scale: 1, pixelFormat: "PDF ", payload: 64 }, + ]), + }); + const entries = unzipSync(normalizeIpa(ipa, null)); + const manifestKey = + "archive.xcarchive/ParsedAssets/Products/Applications/MyApp.app/Assets.json"; + const manifest = JSON.parse(new TextDecoder().decode(entries[manifestKey])); + expect(manifest.assets[0]).toMatchObject({ name: "Logo", vector: true }); + }); + test("skips path-traversal entries", () => { const ipa = zipSync({ "Payload/MyApp.app/Info.plist": strToU8(""), From ffdc97bea80565c2907cfba6bea5655f015515ff Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Tue, 25 Aug 2026 08:32:43 +0000 Subject: [PATCH 2/7] fix(asset-catalog): walk final B-tree child on branch nodes A node with N keys has N+1 children. The previous loop only followed `count` children and dropped the last one on branch nodes, so multi-level trees would miss renditions. The synthetic tests only exercised single-level trees, so the bug wasn't caught. Fixes the sentry[bot] review on #1469. --- packages/cli/src/lib/build/asset-catalog.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/src/lib/build/asset-catalog.ts b/packages/cli/src/lib/build/asset-catalog.ts index 2505754aac..4f90d6e4ca 100644 --- a/packages/cli/src/lib/build/asset-catalog.ts +++ b/packages/cli/src/lib/build/asset-catalog.ts @@ -154,6 +154,12 @@ function collectTreeLeaves( walk(valueIndex); } } + // B-tree node with N keys has N+1 children. The loop consumed `count` + // (keyIndex,valueIndex) pairs; the final child pointer is the next u32. + if (!isLeaf) { + const finalChild = view.getUint32(cursor); + walk(finalChild); + } }; walk(rootNodeId); From faa4f0ace62ea23d773363e1fd5c8af386e72307 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Wed, 26 Aug 2026 14:20:57 +0000 Subject: [PATCH 3/7] feat(build): decode iOS Assets.car renditions to PNGs via CoreUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds real pixel extraction for iOS asset catalogs, requested on the PR: the size/geometry manifest alone can't drive image-optimization insights. Decoding needs Apple's private CoreUI framework, so it ships as an Apple-Silicon-only native helper (native/car-extract, Swift) embedded in the darwin-arm64 SEA binary and run at upload time. On every other platform (or if the helper is unavailable) the CLI falls back to the existing pure-TS size manifest with no decoded images — extraction is strictly additive and never blocks an upload. - native/car-extract: swiftc-compiled CoreUI decoder (car → PNGs + JSON) - script/build.ts: compile + embed the helper as a SEA asset (arm64 only) - asset-catalog-extract.ts: runtime glue (extract asset, exec, fold in) - index.ts: emit decoded PNGs under ParsedAssets/.../images/ and record them in the manifest - docs + command help updated Note: a companion CI change (build darwin-arm64 on PRs so extraction is exercised pre-merge) needs to be applied by a maintainer — the app token lacks workflow write permission. --- apps/cli-docs/src/fragments/commands/build.md | 8 +- packages/cli/native/car-extract/README.md | 45 +++++ packages/cli/native/car-extract/main.swift | 159 ++++++++++++++++ packages/cli/script/build-car-extract.ts | 83 +++++++++ packages/cli/script/build.ts | 16 ++ packages/cli/src/commands/build/upload.ts | 6 +- .../src/lib/build/asset-catalog-extract.ts | 172 ++++++++++++++++++ packages/cli/src/lib/build/index.ts | 98 +++++++--- .../lib/build/asset-catalog-extract.test.ts | 59 ++++++ packages/cli/test/lib/build/index.test.ts | 52 +++++- 10 files changed, 665 insertions(+), 33 deletions(-) create mode 100644 packages/cli/native/car-extract/README.md create mode 100644 packages/cli/native/car-extract/main.swift create mode 100644 packages/cli/script/build-car-extract.ts create mode 100644 packages/cli/src/lib/build/asset-catalog-extract.ts create mode 100644 packages/cli/test/lib/build/asset-catalog-extract.test.ts diff --git a/apps/cli-docs/src/fragments/commands/build.md b/apps/cli-docs/src/fragments/commands/build.md index 693e9cd868..f8a2cc1517 100644 --- a/apps/cli-docs/src/fragments/commands/build.md +++ b/apps/cli-docs/src/fragments/commands/build.md @@ -36,9 +36,11 @@ sentry build download 1234567890 --json upload. **Sentry SaaS only.** - iOS `Assets.car` asset catalogs are parsed into a per-rendition size manifest (`ParsedAssets/.../Assets.json`) so the server gets a per-asset breakdown; the - raw `.car` is still uploaded alongside it. Pixel extraction (which needs - native macOS frameworks) is **not** performed. XCArchive symlinks and Unix - file permissions are preserved. + raw `.car` is still uploaded alongside it. On **macOS (Apple Silicon)** the + renditions are additionally decoded to PNGs under `ParsedAssets/.../images/` + via the native CoreUI framework; on other platforms the manifest carries + size/geometry only. XCArchive symlinks and Unix file permissions are + preserved. - Multiple paths may be uploaded at once; the command exits non-zero if any build fails to upload. - Git metadata (commit, branch, PR number, repo) is **auto-collected in CI** diff --git a/packages/cli/native/car-extract/README.md b/packages/cli/native/car-extract/README.md new file mode 100644 index 0000000000..a3feb54be4 --- /dev/null +++ b/packages/cli/native/car-extract/README.md @@ -0,0 +1,45 @@ +# car-extract + +Apple-Silicon-only native helper that decodes an iOS `Assets.car` asset catalog +into per-rendition PNG images using the private macOS **CoreUI** framework +(`CUICatalog`). This is the one piece that genuinely needs a Mac: CoreUI is not +available on Linux/Windows and cannot be reimplemented in portable code. + +The cross-platform CLI stays pure-TypeScript for everything else; this helper is +compiled with `swiftc` during the `darwin-arm64` build (see +`packages/cli/script/build.ts`) and embedded into that binary as a Node SEA +asset. At runtime the CLI extracts it to a temp dir and runs it (see +`packages/cli/src/lib/build/asset-catalog-extract.ts`). On every other platform, +or if the helper is unavailable, the CLI falls back to the pure-TS size/geometry +manifest with no decoded images. + +## Contract + +``` +car-extract +``` + +- Writes one PNG per decoded rendition into ``. +- Prints a JSON manifest to stdout: + + ```json + { + "images": [ + { "name": "AppIcon", "file": "AppIcon@2x.png", "width": 120, "height": 120, "scale": 2, "bytes": 4096 } + ] + } + ``` + +- Exit code `0` on success (including "no decodable renditions" → empty + `images`), non-zero on a hard failure (bad catalog, CoreUI unavailable). The + caller treats any non-zero exit as "extraction unavailable" and falls back. + +## Building manually + +```sh +swiftc -O -o car-extract main.swift \ + -framework Foundation -framework CoreGraphics -framework ImageIO +``` + +CoreUI is loaded at runtime via the Objective-C runtime (`NSClassFromString`) +rather than linked directly, so the tool builds without private SDK stubs. diff --git a/packages/cli/native/car-extract/main.swift b/packages/cli/native/car-extract/main.swift new file mode 100644 index 0000000000..0b972ead32 --- /dev/null +++ b/packages/cli/native/car-extract/main.swift @@ -0,0 +1,159 @@ +// car-extract — decode an iOS Assets.car into per-rendition PNGs via CoreUI. +// +// CoreUI (CUICatalog/CUINamedImage) is a private macOS framework, so it is +// resolved dynamically at runtime through the Objective-C runtime rather than +// linked against a private SDK. If CoreUI can't be loaded, or the catalog can't +// be opened, the tool exits non-zero and the CLI falls back to the pure-TS +// size-only manifest. +// +// Usage: car-extract +// stdout: {"images":[{"name","file","width","height","scale","bytes"}, ...]} + +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// One decoded rendition, serialized into the JSON manifest. +struct DecodedImage: Encodable { + let name: String + let file: String + let width: Int + let height: Int + let scale: Int + let bytes: Int +} + +struct Manifest: Encodable { + let images: [DecodedImage] +} + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("car-extract: \(message)\n".utf8)) + exit(1) +} + +let args = CommandLine.arguments +guard args.count == 3 else { + fail("usage: car-extract ") +} +let catalogPath = args[1] +let outputDir = args[2] + +try? FileManager.default.createDirectory( + atPath: outputDir, withIntermediateDirectories: true) + +// CUICatalog(URL:error:) opens the catalog; allImageNames() lists renditions; +// imagesWithName: returns CUINamedImage instances (one per scale/idiom). +guard let catalogClass = NSClassFromString("CUICatalog") as? NSObject.Type else { + fail("CoreUI (CUICatalog) is unavailable on this system") +} + +let catalogURL = URL(fileURLWithPath: catalogPath) +let catalog = catalogClass.init() +let initSelector = NSSelectorFromString("initWithURL:error:") +guard catalog.responds(to: initSelector) else { + fail("CUICatalog does not respond to initWithURL:error:") +} + +// Invoke -[CUICatalog initWithURL:error:] via NSInvocation-free perform. The +// private API returns a freshly-initialized catalog or nil on error. +typealias InitFn = @convention(c) (NSObject, Selector, NSURL, UnsafeMutableRawPointer?) -> NSObject? +let initImp = catalog.method(for: initSelector) +let initCall = unsafeBitCast(initImp, to: InitFn.self) +guard let openedCatalog = initCall(catalog, initSelector, catalogURL as NSURL, nil) else { + fail("could not open asset catalog at \(catalogPath)") +} + +let allNamesSelector = NSSelectorFromString("allImageNames") +guard openedCatalog.responds(to: allNamesSelector), + let names = openedCatalog.perform(allNamesSelector)?.takeUnretainedValue() as? [String] +else { + fail("CUICatalog does not expose allImageNames") +} + +let imagesSelector = NSSelectorFromString("imagesWithName:") +typealias ImagesFn = @convention(c) (NSObject, Selector, NSString) -> NSArray? +guard openedCatalog.responds(to: imagesSelector) else { + fail("CUICatalog does not respond to imagesWithName:") +} +let imagesImp = openedCatalog.method(for: imagesSelector) +let imagesCall = unsafeBitCast(imagesImp, to: ImagesFn.self) + +/// Write a CGImage to PNG on disk, returning the byte size written. +func writePng(_ image: CGImage, to path: String) -> Int? { + let url = URL(fileURLWithPath: path) as CFURL + let type = UTType.png.identifier as CFString + guard let dest = CGImageDestinationCreateWithURL(url, type, 1, nil) else { + return nil + } + CGImageDestinationAddImage(dest, image, nil) + guard CGImageDestinationFinalize(dest) else { + return nil + } + let attrs = try? FileManager.default.attributesOfItem(atPath: path) + return (attrs?[.size] as? Int) ?? 0 +} + +/// Sanitize a rendition name into a filesystem-safe basename. +func safeName(_ name: String) -> String { + let allowed = CharacterSet(charactersIn: + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-") + return String(name.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }) +} + +var decoded: [DecodedImage] = [] +var usedFiles = Set() + +for name in names { + guard let namedImages = imagesCall(openedCatalog, imagesSelector, name as NSString) else { + continue + } + for case let named as NSObject in namedImages { + // CUINamedImage exposes -image (CGImageRef) and -scale (CGFloat). + let imageSelector = NSSelectorFromString("image") + guard named.responds(to: imageSelector) else { continue } + typealias ImageFn = @convention(c) (NSObject, Selector) -> CGImage? + let imageImp = named.method(for: imageSelector) + let imageCall = unsafeBitCast(imageImp, to: ImageFn.self) + guard let cgImage = imageCall(named, imageSelector) else { continue } + + var scale = 1 + let scaleSelector = NSSelectorFromString("scale") + if named.responds(to: scaleSelector) { + typealias ScaleFn = @convention(c) (NSObject, Selector) -> CGFloat + let scaleImp = named.method(for: scaleSelector) + let scaleCall = unsafeBitCast(scaleImp, to: ScaleFn.self) + let raw = scaleCall(named, scaleSelector) + if raw > 0 { scale = Int(raw.rounded()) } + } + + var file = "\(safeName(name))@\(scale)x.png" + var counter = 1 + while usedFiles.contains(file) { + file = "\(safeName(name))@\(scale)x-\(counter).png" + counter += 1 + } + usedFiles.insert(file) + + let outPath = (outputDir as NSString).appendingPathComponent(file) + guard let bytes = writePng(cgImage, to: outPath) else { continue } + decoded.append( + DecodedImage( + name: name, + file: file, + width: cgImage.width, + height: cgImage.height, + scale: scale, + bytes: bytes)) + } +} + +decoded.sort { $0.file < $1.file } + +let encoder = JSONEncoder() +encoder.outputFormatting = [.sortedKeys] +guard let json = try? encoder.encode(Manifest(images: decoded)) else { + fail("failed to encode manifest") +} +FileHandle.standardOutput.write(json) diff --git a/packages/cli/script/build-car-extract.ts b/packages/cli/script/build-car-extract.ts new file mode 100644 index 0000000000..bb4ed3500e --- /dev/null +++ b/packages/cli/script/build-car-extract.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env tsx + +/** + * Compile the `car-extract` native helper (Swift → macho binary). + * + * `car-extract` decodes iOS `Assets.car` renditions to PNGs via the private + * macOS CoreUI framework — the one part of build-upload that genuinely needs a + * Mac. It is compiled only for the `darwin-arm64` target and embedded into that + * SEA binary as an asset (see `build.ts`), and used at runtime by + * `src/lib/build/asset-catalog-extract.ts`. Every other platform ships without + * it and falls back to the pure-TS size manifest. + * + * Returns the path to the compiled binary, or `null` when compilation isn't + * possible (not on macOS, or `swiftc` unavailable) — the caller then builds + * without embedding the helper. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +/** Source and output paths for the helper, relative to package root. */ +const SOURCE = "native/car-extract/main.swift"; +const OUTPUT = "native/car-extract/car-extract"; + +/** Whether `swiftc` is on PATH. */ +function hasSwiftc(): boolean { + try { + execFileSync("swiftc", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * Compile the helper for macOS arm64. No-op (returns null) off macOS or when + * `swiftc` is missing. + */ +export function buildCarExtract(): string | null { + if (process.platform !== "darwin") { + console.log(" car-extract: skipped (not macOS)"); + return null; + } + if (!hasSwiftc()) { + console.log(" car-extract: skipped (swiftc not found)"); + return null; + } + if (!existsSync(SOURCE)) { + console.log(` car-extract: skipped (missing ${SOURCE})`); + return null; + } + + console.log(" Compiling car-extract (Swift → macho)..."); + execFileSync( + "swiftc", + [ + "-O", + "-target", + "arm64-apple-macos11", + "-o", + OUTPUT, + SOURCE, + "-framework", + "Foundation", + "-framework", + "CoreGraphics", + "-framework", + "ImageIO", + ], + { stdio: "inherit" } + ); + console.log(` -> ${OUTPUT}`); + return OUTPUT; +} + +// Allow running standalone (`tsx script/build-car-extract.ts`) for local dev. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const out = buildCarExtract(); + if (!out) { + process.exit(0); + } +} diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index 5c20c56d5a..9b537a92f8 100644 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -35,6 +35,7 @@ import { promisify } from "node:util"; import { gzip } from "node:zlib"; import { build as esbuild } from "esbuild"; import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; +import { buildCarExtract } from "./build-car-extract.js"; import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; import { textImportPlugin } from "./text-import-plugin.js"; @@ -344,6 +345,21 @@ async function compileAllTargets( copyFileSync(DIF_WASM_SRC, DIF_WASM); assetArgs.push("--assets", DIF_WASM); + // Embed the CoreUI pixel-extraction helper for darwin-arm64 only. It is a + // macho binary compiled with swiftc (macOS build hosts only) and loaded at + // runtime via node:sea.getRawAsset(CAR_EXTRACT_ASSET_KEY) — the asset key + // MUST equal this path string (see src/lib/build/asset-catalog-extract.ts). + // Off macOS, or when swiftc is unavailable, the helper is omitted and the CLI + // falls back to the pure-TS size manifest. + if (targets.some((t) => t.os === "darwin" && t.arch === "arm64")) { + const carExtract = buildCarExtract(); + if (carExtract) { + const CAR_EXTRACT = `${BUILD_DIR}/car-extract`; + copyFileSync(carExtract, CAR_EXTRACT); + assetArgs.push("--assets", CAR_EXTRACT); + } + } + console.log( ` Step 2: Compiling ${platforms.length} target(s) (Node SEA via fossilize)...` ); diff --git a/packages/cli/src/commands/build/upload.ts b/packages/cli/src/commands/build/upload.ts index e447a08495..74e6e71e72 100644 --- a/packages/cli/src/commands/build/upload.ts +++ b/packages/cli/src/commands/build/upload.ts @@ -156,8 +156,10 @@ export const uploadCommand = buildCommand({ "chunk-upload + assemble protocol.\n\n" + "Supported formats: Android APK/AAB, iOS XCArchive (a directory) and IPA. " + "iOS Assets.car asset catalogs are parsed into a per-asset size manifest " + - "(ParsedAssets/.../Assets.json); pixel extraction still requires macOS " + - "and is not performed. This feature only works with Sentry SaaS.\n\n" + + "(ParsedAssets/.../Assets.json). On macOS (Apple Silicon) renditions are " + + "also decoded to PNGs under ParsedAssets/.../images/ via CoreUI; on other " + + "platforms the manifest carries size/geometry only. This feature only " + + "works with Sentry SaaS.\n\n" + "Usage:\n" + " sentry build upload ./app-release.apk\n" + " sentry build upload ./MyApp.xcarchive\n" + diff --git a/packages/cli/src/lib/build/asset-catalog-extract.ts b/packages/cli/src/lib/build/asset-catalog-extract.ts new file mode 100644 index 0000000000..df1675e6ed --- /dev/null +++ b/packages/cli/src/lib/build/asset-catalog-extract.ts @@ -0,0 +1,172 @@ +/** + * Native pixel extraction for iOS `Assets.car` asset catalogs. + * + * Decoding renditions to actual images requires Apple's private macOS CoreUI + * framework, which only exists on macOS and cannot be reimplemented portably. + * The rest of the build pipeline stays pure-TypeScript and cross-platform (see + * {@link ./asset-catalog.ts} for the size/geometry manifest that works + * everywhere); this module is the one macOS-only path. + * + * The decoder ships as a small Swift helper (`native/car-extract`) compiled for + * `darwin-arm64` and embedded into that SEA binary as an asset (see + * `script/build.ts`). At runtime we: + * 1. locate the helper (SEA asset → extract to temp; dev → the built binary), + * 2. write the `.car` bytes to a temp file, + * 3. run the helper, which renders each rendition to PNG and prints a JSON + * manifest of what it wrote, + * 4. read the PNGs back into memory. + * + * On any non-macOS-arm64 platform, or if the helper is missing or fails, this + * returns `null` and the caller falls back to the size-only manifest. Pixel + * extraction is therefore strictly additive: it never blocks an upload. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { logger } from "../logger.js"; + +const log = logger.withTag("build.asset-catalog.extract"); +const _require = createRequire(import.meta.url); + +/** + * SEA asset key for the embedded `car-extract` helper. Must match the + * `--assets` argument passed to fossilize in `script/build.ts`. + */ +const CAR_EXTRACT_ASSET_KEY = "dist-build/car-extract"; + +/** Path to the helper built into the dev tree by `script/build-car-extract.ts`. */ +const CAR_EXTRACT_DEV_PATH = new URL( + "../../../native/car-extract/car-extract", + import.meta.url +); + +/** Upper bound on the helper's JSON stdout (16 MB of manifest is absurdly large). */ +const MANIFEST_MAX_BUFFER = 16 * 1024 * 1024; + +/** One image decoded from a rendition by the native helper. */ +export type ExtractedImage = { + /** Rendition name (e.g. `"AppIcon"`). */ + name: string; + /** Output filename the helper wrote (e.g. `"AppIcon@2x.png"`). */ + file: string; + /** Decoded pixel width. */ + width: number; + /** Decoded pixel height. */ + height: number; + /** Scale factor (1, 2, 3). */ + scale: number; + /** PNG byte size on disk. */ + bytes: number; + /** The decoded PNG bytes. */ + content: Uint8Array; +}; + +/** Shape of the helper's JSON manifest (without the in-memory bytes). */ +type HelperManifest = { + images: Array>; +}; + +/** Returns the SEA API when running inside a Node SEA binary, else null. */ +function seaApi(): { + isSea: () => boolean; + getRawAsset: (key: string) => ArrayBuffer; +} | null { + try { + const sea = _require("node:sea") as { + isSea?: () => boolean; + getRawAsset?: (key: string) => ArrayBuffer; + }; + if (sea.isSea?.() && sea.getRawAsset) { + return { isSea: () => true, getRawAsset: sea.getRawAsset }; + } + } catch (err) { + log.debug("node:sea unavailable; treating as non-SEA runtime", err); + } + return null; +} + +/** Whether the current runtime can host the CoreUI helper (macOS on arm64). */ +function platformSupportsExtraction(): boolean { + return process.platform === "darwin" && process.arch === "arm64"; +} + +/** + * Resolve the `car-extract` helper to an executable path, materializing the SEA + * asset to a temp file when needed. Returns the path plus an optional cleanup + * directory the caller must remove, or `null` if no helper is available. + */ +function resolveHelper(): { path: string; cleanupDir?: string } | null { + const sea = seaApi(); + if (sea) { + let raw: ArrayBuffer; + try { + raw = sea.getRawAsset(CAR_EXTRACT_ASSET_KEY); + } catch (err) { + log.debug("car-extract helper not embedded in this binary", err); + return null; + } + const dir = mkdtempSync(join(tmpdir(), "sentry-car-extract-")); + const path = join(dir, "car-extract"); + writeFileSync(path, new Uint8Array(raw), { mode: 0o755 }); + return { path, cleanupDir: dir }; + } + + // Dev / npm: use the helper built next to the source, if present. + const devPath = CAR_EXTRACT_DEV_PATH.pathname; + if (existsSync(devPath)) { + return { path: devPath }; + } + log.debug("car-extract helper not found for non-SEA runtime"); + return null; +} + +/** + * Decode an `Assets.car` into per-rendition PNG images using the native macOS + * helper. Returns `null` (never throws) when extraction isn't possible — wrong + * platform, missing helper, or a helper failure — so callers can fall back to + * the size-only manifest. + * + * @param carRelPath - Archive-relative path of the catalog (for log context). + * @param content - The raw `.car` bytes. + */ +export function extractAssetCatalogImages( + carRelPath: string, + content: Uint8Array +): ExtractedImage[] | null { + if (!platformSupportsExtraction()) { + return null; + } + + const helper = resolveHelper(); + if (!helper) { + return null; + } + + const workDir = mkdtempSync(join(tmpdir(), "sentry-car-")); + const inputPath = join(workDir, "input.car"); + const outputDir = join(workDir, "out"); + try { + writeFileSync(inputPath, content); + const stdout = execFileSync(helper.path, [inputPath, outputDir], { + encoding: "buffer", + maxBuffer: MANIFEST_MAX_BUFFER, + stdio: ["ignore", "pipe", "pipe"], + }); + const manifest = JSON.parse(stdout.toString("utf-8")) as HelperManifest; + return manifest.images.map((img) => ({ + ...img, + content: readFileSync(join(outputDir, img.file)), + })); + } catch (err) { + log.debug(`Native asset-catalog extraction failed for ${carRelPath}`, err); + return null; + } finally { + rmSync(workDir, { recursive: true, force: true }); + if (helper.cleanupDir) { + rmSync(helper.cleanupDir, { recursive: true, force: true }); + } + } +} diff --git a/packages/cli/src/lib/build/index.ts b/packages/cli/src/lib/build/index.ts index 5c6c6d4936..b14ea713cf 100644 --- a/packages/cli/src/lib/build/index.ts +++ b/packages/cli/src/lib/build/index.ts @@ -33,6 +33,10 @@ import { CLI_VERSION } from "../constants.js"; import { ValidationError } from "../errors.js"; import { logger } from "../logger.js"; import { type AssetCatalogEntry, parseAssetCatalog } from "./asset-catalog.js"; +import { + type ExtractedImage, + extractAssetCatalogImages, +} from "./asset-catalog-extract.js"; const log = logger.withTag("build.normalize"); @@ -47,35 +51,77 @@ function isAssetCatalogPath(relPath: string): boolean { return relPath === "Assets.car" || relPath.endsWith("/Assets.car"); } -/** The ParsedAssets manifest path for an `Assets.car` at `carRelPath`. */ -function manifestPathFor(archiveRoot: string, carRelPath: string): string { +/** Subdirectory (under a catalog's ParsedAssets dir) holding decoded PNGs. */ +const EXTRACTED_IMAGES_DIR = "images"; + +/** The ParsedAssets directory for an `Assets.car` at `carRelPath`. */ +function parsedAssetsDirFor(archiveRoot: string, carRelPath: string): string { const slash = carRelPath.lastIndexOf("/"); const dir = slash === -1 ? "" : carRelPath.slice(0, slash); const prefix = dir ? `${dir}/` : ""; - return `${archiveRoot}/${PARSED_ASSETS_DIR}/${prefix}${ASSET_CATALOG_MANIFEST}`; + return `${archiveRoot}/${PARSED_ASSETS_DIR}/${prefix}`; } +/** A generated ParsedAssets entry (manifest JSON or a decoded image). */ +type ParsedAssetEntry = { path: string; content: Uint8Array }; + /** Serialize an asset-catalog manifest to deterministic JSON bytes. */ -function manifestBytes(assets: AssetCatalogEntry[]): Uint8Array { - return strToU8(`${JSON.stringify({ assets }, null, 2)}\n`); +function manifestBytes( + assets: AssetCatalogEntry[], + images: ExtractedImage[] | null +): Uint8Array { + const manifest: { + assets: AssetCatalogEntry[]; + images?: Array>; + } = { assets }; + if (images && images.length > 0) { + // Drop the in-memory PNG bytes; the manifest records metadata only, the + // decoded files live alongside it under `images/`. + manifest.images = images.map(({ content: _content, ...meta }) => meta); + } + return strToU8(`${JSON.stringify(manifest, null, 2)}\n`); } /** - * Parse an `Assets.car` into a manifest, returning `null` if it can't be read. + * Build the ParsedAssets entries for an `Assets.car`: a size/geometry manifest + * (JSON) plus, on macOS arm64, the decoded per-rendition PNGs. * - * The `.car` format is only loosely documented, so a parse failure on an - * unusual catalog is expected; callers fall back to shipping the raw `.car`. + * Returns `null` when the catalog can't be parsed at all, so callers fall back + * to shipping the raw `.car` unchanged. The `.car` format is only loosely + * documented, so a parse failure on an unusual catalog is expected. Pixel + * extraction is additive: on any non-macOS-arm64 platform (or a helper failure) + * the manifest is still emitted with size/geometry only and no images. */ -function tryParseAssetCatalog( +function buildParsedAssets( + archiveRoot: string, carRelPath: string, content: Uint8Array -): AssetCatalogEntry[] | null { +): ParsedAssetEntry[] | null { + let assets: AssetCatalogEntry[]; try { - return parseAssetCatalog(content); + assets = parseAssetCatalog(content); } catch (err) { log.debug(`Failed to parse asset catalog ${carRelPath}`, err); return null; } + + const images = extractAssetCatalogImages(carRelPath, content); + const baseDir = parsedAssetsDirFor(archiveRoot, carRelPath); + const entries: ParsedAssetEntry[] = [ + { + path: `${baseDir}${ASSET_CATALOG_MANIFEST}`, + content: manifestBytes(assets, images), + }, + ]; + if (images) { + for (const image of images) { + entries.push({ + path: `${baseDir}${EXTRACTED_IMAGES_DIR}/${image.file}`, + content: image.content, + }); + } + } + return entries; } /** A recognized mobile build format. */ @@ -357,10 +403,10 @@ async function collectArchiveEntries(root: string): Promise { * * Each `Assets.car` asset catalog is additionally parsed into a per-rendition * size manifest written under `/ParsedAssets//Assets.json` (the - * raw `.car` is still uploaded as-is). Unlike the legacy CLI this does not - * decode pixels — that needed native macOS frameworks — but it gives preprod - * size analysis the per-asset breakdown it needs on every platform. See - * {@link parseAssetCatalog}. + * raw `.car` is still uploaded as-is). On macOS arm64 the renditions are also + * decoded to PNGs (via the native CoreUI helper) under + * `/ParsedAssets//images/`; on every other platform the manifest + * carries size/geometry only. See {@link buildParsedAssets}. * * The whole directory is read into memory; a very large XCArchive (e.g. with * dSYMs) could exceed Node's ~2 GiB Buffer cap — streaming is a follow-up. @@ -383,12 +429,11 @@ export async function normalizeBuildDirectory( { level: 0, mtime: FIXED_MTIME, os: ZIP_OS_UNIX, attrs: entry.attrs }, ]; if (isAssetCatalogPath(entry.relPath)) { - const assets = tryParseAssetCatalog(entry.relPath, entry.content); - if (assets) { - entries[manifestPathFor(dirName, entry.relPath)] = [ - manifestBytes(assets), - ENTRY_OPTIONS, - ]; + const parsed = buildParsedAssets(dirName, entry.relPath, entry.content); + if (parsed) { + for (const asset of parsed) { + entries[asset.path] = [asset.content, ENTRY_OPTIONS]; + } } } } @@ -485,12 +530,11 @@ export function normalizeIpa( const productRelPath = `Products/Applications/${stripped}`; archiveEntries.push([`${archiveDir}/${productRelPath}`, bytes]); if (isAssetCatalogPath(productRelPath)) { - const assets = tryParseAssetCatalog(productRelPath, bytes); - if (assets) { - archiveEntries.push([ - manifestPathFor(archiveDir, productRelPath), - manifestBytes(assets), - ]); + const parsed = buildParsedAssets(archiveDir, productRelPath, bytes); + if (parsed) { + for (const asset of parsed) { + archiveEntries.push([asset.path, asset.content]); + } } } } diff --git a/packages/cli/test/lib/build/asset-catalog-extract.test.ts b/packages/cli/test/lib/build/asset-catalog-extract.test.ts new file mode 100644 index 0000000000..e2e39caa10 --- /dev/null +++ b/packages/cli/test/lib/build/asset-catalog-extract.test.ts @@ -0,0 +1,59 @@ +/** + * Tests for the native `Assets.car` pixel-extraction glue. + * + * The real decoder is a macOS-only CoreUI helper (compiled for darwin-arm64 and + * embedded as a SEA asset), so these tests cover the platform guard and the + * non-fatal fallback contract — the behavior every non-macOS-arm64 runner and + * every helper failure must exhibit. The decoder itself is exercised on the + * darwin-arm64 CI runner, not here. + */ + +import { describe, expect, test, vi } from "vitest"; +import { extractAssetCatalogImages } from "../../../src/lib/build/asset-catalog-extract.js"; + +describe("extractAssetCatalogImages", () => { + test("returns null on non-macOS-arm64 platforms", () => { + const platform = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + try { + expect( + extractAssetCatalogImages("MyApp.app/Assets.car", new Uint8Array([1, 2])) + ).toBeNull(); + } finally { + platform.mockRestore(); + } + }); + + test("returns null on macOS x64 (extraction is arm64-only)", () => { + const platform = vi + .spyOn(process, "platform", "get") + .mockReturnValue("darwin"); + const arch = vi.spyOn(process, "arch", "get").mockReturnValue("x64"); + try { + expect( + extractAssetCatalogImages("Assets.car", new Uint8Array([1, 2])) + ).toBeNull(); + } finally { + platform.mockRestore(); + arch.mockRestore(); + } + }); + + test("returns null (never throws) when no helper is available", () => { + // On a darwin-arm64 dev box without the compiled helper, extraction must + // degrade to the size-only manifest rather than fail the upload. + const platform = vi + .spyOn(process, "platform", "get") + .mockReturnValue("darwin"); + const arch = vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); + try { + expect(() => + extractAssetCatalogImages("Assets.car", new Uint8Array([1, 2])) + ).not.toThrow(); + } finally { + platform.mockRestore(); + arch.mockRestore(); + } + }); +}); diff --git a/packages/cli/test/lib/build/index.test.ts b/packages/cli/test/lib/build/index.test.ts index e47c6ee507..f18f42715c 100644 --- a/packages/cli/test/lib/build/index.test.ts +++ b/packages/cli/test/lib/build/index.test.ts @@ -15,7 +15,8 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { strToU8, unzipSync, zipSync } from "fflate"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import * as assetCatalogExtract from "../../../src/lib/build/asset-catalog-extract.js"; import { buildFakeCar } from "./car-fixture.js"; import { detectBuildFormat, @@ -215,6 +216,55 @@ describe("normalizeBuildDirectory", () => { ).toBe(false); }); + test("folds native-extracted images into ParsedAssets and the manifest", async () => { + // The native CoreUI helper only runs on macOS arm64; mock it so the folding + // path (manifest.images + images/*.png) is exercised on every runner. + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); + const extract = vi + .spyOn(assetCatalogExtract, "extractAssetCatalogImages") + .mockReturnValue([ + { + name: "AppIcon", + file: "AppIcon@2x.png", + width: 120, + height: 120, + scale: 2, + bytes: png.length, + content: png, + }, + ]); + try { + const xc = fakeXcarchive(); + writeFileSync( + join(xc, "Products", "Applications", "MyApp.app", "Assets.car"), + buildFakeCar([ + { name: "AppIcon", width: 120, height: 120, scale: 2, pixelFormat: "ARGB", payload: 32 }, + ]) + ); + + const entries = unzipSync(await normalizeBuildDirectory(xc, null)); + const base = + "MyApp.xcarchive/ParsedAssets/Products/Applications/MyApp.app"; + // The decoded PNG is written under images/ next to the manifest. + expect(entries[`${base}/images/AppIcon@2x.png`]).toEqual(png); + const manifest = JSON.parse( + new TextDecoder().decode(entries[`${base}/Assets.json`]) + ); + expect(manifest.images).toEqual([ + { + name: "AppIcon", + file: "AppIcon@2x.png", + width: 120, + height: 120, + scale: 2, + bytes: png.length, + }, + ]); + } finally { + extract.mockRestore(); + } + }); + // Symlinks require privileges on Windows; the unit suite runs on Linux. test.skipIf(process.platform === "win32")( "preserves symlinks as entries (stores the target path, not followed content)", From 04451a035302ea9674aa27cfd14c32509c2a1f80 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 09:27:51 +0000 Subject: [PATCH 4/7] test(build): add macOS integration test for CoreUI car-extract Compiles the Swift helper with swiftc, builds a real Assets.car from an .xcassets fixture via actool, runs the helper, and asserts real PNGs come out with correct geometry and PNG magic. Gated to macOS with the Xcode CLI tools (actool/swiftc) via describe.skipIf, so it runs on the darwin-arm64 CI runner and skips elsewhere. This exercises real CoreUI pixel decoding end to end rather than the mocked unit path. --- .../lib/build/car-extract.integration.test.ts | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 packages/cli/test/lib/build/car-extract.integration.test.ts diff --git a/packages/cli/test/lib/build/car-extract.integration.test.ts b/packages/cli/test/lib/build/car-extract.integration.test.ts new file mode 100644 index 0000000000..394d57b9be --- /dev/null +++ b/packages/cli/test/lib/build/car-extract.integration.test.ts @@ -0,0 +1,161 @@ +/** + * End-to-end integration test for the native CoreUI `car-extract` helper. + * + * Unlike the unit tests (which mock the decoder), this compiles the Swift + * helper with `swiftc`, builds a *real* `Assets.car` from an `.xcassets` + * fixture via `actool`, runs the helper against it, and asserts actual PNGs + * come out with sane geometry. It is the only check that exercises real pixel + * decoding via CoreUI. + * + * Requires macOS with the Xcode command-line tools (`actool`, `swiftc`), so it + * is skipped everywhere else. On CI it runs on the `darwin-arm64` build runner + * (see the PR build matrix in `.github/workflows/ci.yml`). + */ + +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { buildCarExtract } from "../../../script/build-car-extract.js"; + +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../../.."); + +/** Whether the macOS asset-catalog toolchain is available. */ +function hasAssetToolchain(): boolean { + if (process.platform !== "darwin") { + return false; + } + try { + execFileSync("actool", ["--version"], { stdio: "ignore" }); + execFileSync("swiftc", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** A 10x10 opaque red PNG (generated at authoring time, not decoded here). */ +const RED_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFElEQVR4nGM4IafxnxjMMKqQvgoB" + + "IgzNFQJIdHQAAAAASUVORK5CYII="; + +/** + * Build a real `Assets.car` from a synthetic `.xcassets` using `actool`. + * + * @returns Path to the compiled `Assets.car`. + */ +function compileAssetCatalog(workDir: string): string { + const xcassets = join(workDir, "Assets.xcassets"); + const imageset = join(xcassets, "Logo.imageset"); + mkdirSync(imageset, { recursive: true }); + + writeFileSync( + join(xcassets, "Contents.json"), + JSON.stringify({ info: { author: "xcode", version: 1 } }) + ); + writeFileSync(join(imageset, "logo.png"), Buffer.from(RED_PNG_BASE64, "base64")); + writeFileSync( + join(imageset, "Contents.json"), + JSON.stringify({ + images: [{ idiom: "universal", scale: "1x", filename: "logo.png" }], + info: { author: "xcode", version: 1 }, + }) + ); + + const outDir = join(workDir, "compiled"); + mkdirSync(outDir, { recursive: true }); + execFileSync( + "actool", + [ + xcassets, + "--compile", + outDir, + "--platform", + "iphoneos", + "--minimum-deployment-target", + "15.0", + "--output-format", + "human-readable-text", + ], + { stdio: ["ignore", "ignore", "inherit"] } + ); + + const car = join(outDir, "Assets.car"); + if (!existsSync(car)) { + throw new Error(`actool did not produce ${car}`); + } + return car; +} + +const toolchainAvailable = hasAssetToolchain(); +const dirs: string[] = []; + +describe.skipIf(!toolchainAvailable)("car-extract (native CoreUI)", () => { + let helper: string | null = null; + + beforeAll(() => { + // Compile the Swift helper against the package root (SOURCE/OUTPUT are + // resolved relative to cwd there). + const prev = process.cwd(); + process.chdir(PKG_ROOT); + try { + helper = buildCarExtract(); + } finally { + process.chdir(prev); + } + }); + + afterAll(() => { + while (dirs.length > 0) { + const d = dirs.pop(); + if (d) { + rmSync(d, { recursive: true, force: true }); + } + } + }); + + type HelperImage = { + name: string; + file: string; + width: number; + height: number; + scale: number; + bytes: number; + }; + + test("decodes a real Assets.car into PNGs with sane geometry", () => { + expect(helper).not.toBeNull(); + + const workDir = mkdtempSync(join(tmpdir(), "car-extract-it-")); + dirs.push(workDir); + const car = compileAssetCatalog(workDir); + + const outDir = join(workDir, "out"); + const stdout = execFileSync(helper ?? "", [car, outDir], { + encoding: "utf-8", + }); + const manifest = JSON.parse(stdout) as { images: HelperImage[] }; + + // The fixture has one rendition; CoreUI should decode at least that. + const logo = manifest.images.find((img) => img.name === "Logo"); + expect(logo).toMatchObject({ width: 10, height: 10 }); + expect(logo?.bytes).toBeGreaterThan(0); + + // The manifest points at a real PNG on disk (magic bytes, not a stub). + const bytes = readFileSync(join(outDir, logo?.file ?? "")); + expect(Array.from(bytes.subarray(0, 8))).toEqual([ + 137, 80, 78, 71, 13, 10, 26, 10, + ]); + expect(readdirSync(outDir).length).toBe(manifest.images.length); + }); +}); From ed36c26d40fddf1165605a1c3bc48a616ad1b3ee Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 09:45:37 +0000 Subject: [PATCH 5/7] fix(build): load CoreUI via dlopen and run actool through xcrun Addresses Cursor Bugbot findings on the pixel-extraction path: - car-extract never loaded CoreUI: NSClassFromString only finds classes already registered in the process, and the private framework is neither linked nor dlopened, so the lookup returned nil and extraction always fell back. dlopen the CoreUI framework before resolving CUICatalog. - integration test always skipped: actool lives inside Xcode and isn't on PATH, so the bare invocation failed the toolchain check and skipIf skipped forever. Locate it with and run it as . - helper path broke after chdir restore: buildCarExtract returns a package-relative path, but cwd is restored before it runs. Resolve the returned path against the package root so execFileSync finds the binary. --- packages/cli/native/car-extract/main.swift | 12 ++++++++++++ .../test/lib/build/car-extract.integration.test.ts | 14 +++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/cli/native/car-extract/main.swift b/packages/cli/native/car-extract/main.swift index 0b972ead32..bd659648d7 100644 --- a/packages/cli/native/car-extract/main.swift +++ b/packages/cli/native/car-extract/main.swift @@ -43,6 +43,18 @@ let outputDir = args[2] try? FileManager.default.createDirectory( atPath: outputDir, withIntermediateDirectories: true) +// CoreUI is a private framework, so it isn't linked at build time and its +// classes aren't registered in the process until the dylib is loaded. +// dlopen it first; NSClassFromString only finds already-registered classes and +// would otherwise return nil even on a system that has CoreUI. +let coreUIPaths = [ + "/System/Library/PrivateFrameworks/CoreUI.framework/CoreUI", + "/System/Library/PrivateFrameworks/CoreUI.framework/Versions/A/CoreUI", +] +if !coreUIPaths.contains(where: { dlopen($0, RTLD_LAZY) != nil }) { + fail("could not load the CoreUI private framework") +} + // CUICatalog(URL:error:) opens the catalog; allImageNames() lists renditions; // imagesWithName: returns CUINamedImage instances (one per scale/idiom). guard let catalogClass = NSClassFromString("CUICatalog") as? NSObject.Type else { diff --git a/packages/cli/test/lib/build/car-extract.integration.test.ts b/packages/cli/test/lib/build/car-extract.integration.test.ts index 394d57b9be..c6fb2c4b76 100644 --- a/packages/cli/test/lib/build/car-extract.integration.test.ts +++ b/packages/cli/test/lib/build/car-extract.integration.test.ts @@ -36,7 +36,8 @@ function hasAssetToolchain(): boolean { return false; } try { - execFileSync("actool", ["--version"], { stdio: "ignore" }); + // actool ships inside Xcode, not on PATH — locate it via xcrun. + execFileSync("xcrun", ["--find", "actool"], { stdio: "ignore" }); execFileSync("swiftc", ["--version"], { stdio: "ignore" }); return true; } catch { @@ -75,8 +76,9 @@ function compileAssetCatalog(workDir: string): string { const outDir = join(workDir, "compiled"); mkdirSync(outDir, { recursive: true }); execFileSync( - "actool", + "xcrun", [ + "actool", xcassets, "--compile", outDir, @@ -104,12 +106,14 @@ describe.skipIf(!toolchainAvailable)("car-extract (native CoreUI)", () => { let helper: string | null = null; beforeAll(() => { - // Compile the Swift helper against the package root (SOURCE/OUTPUT are - // resolved relative to cwd there). + // buildCarExtract resolves SOURCE/OUTPUT relative to cwd, so run it from + // the package root. It returns a package-relative path; resolve it against + // PKG_ROOT so it's still valid after the cwd is restored. const prev = process.cwd(); process.chdir(PKG_ROOT); try { - helper = buildCarExtract(); + const out = buildCarExtract(); + helper = out === null ? null : join(PKG_ROOT, out); } finally { process.chdir(prev); } From 9a22175903761980f9196756ce275ee01cf1b746 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 10:41:41 +0000 Subject: [PATCH 6/7] test(e2e): raise bundle-build hook timeout to 120s The bundle beforeAll hook spawns a full `pnpm run bundle` (redundant generate:docs + generate:sdk + esbuild). On a cold CI runner that takes ~62s, past the 60s hook timeout, so the E2E bundle/library suites intermittently failed with "Bundle not built". Raise the hook timeout to 120s and the cross-worker wait deadline to 110s to match the real worst-case build cost. --- packages/cli/test/e2e/bundle-setup.ts | 4 +++- packages/cli/test/e2e/bundle.test.ts | 2 +- packages/cli/test/e2e/library.test.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/cli/test/e2e/bundle-setup.ts b/packages/cli/test/e2e/bundle-setup.ts index ac18e291f1..4514d378e0 100644 --- a/packages/cli/test/e2e/bundle-setup.ts +++ b/packages/cli/test/e2e/bundle-setup.ts @@ -80,7 +80,9 @@ async function runBundleBuild(): Promise { } async function waitForBundle(): Promise { - const deadline = Date.now() + 55_000; + // Must stay under the beforeAll hook timeout (120s) but above the builder's + // worst-case cold-runner bundle time (~65s: redundant codegen + esbuild). + const deadline = Date.now() + 110_000; while (Date.now() < deadline) { if (existsSync(BUNDLE_INDEX_PATH) && !existsSync(LOCK_DIR)) { return; diff --git a/packages/cli/test/e2e/bundle.test.ts b/packages/cli/test/e2e/bundle.test.ts index c188426ec3..d025e1421d 100644 --- a/packages/cli/test/e2e/bundle.test.ts +++ b/packages/cli/test/e2e/bundle.test.ts @@ -51,7 +51,7 @@ const INK_APP_PATH = join(ROOT_DIR, "dist/ink-app.js"); describe("npm bundle", () => { beforeAll(async () => { await ensureBundleBuilt(); - }, 60_000); // Bundle can take a while + }, 120_000); // Bundle can take a while — it re-runs codegen + esbuild on a cold runner test("bundle file exists", () => { expect(existsSync(BUNDLE_BIN_PATH)).toBe(true); diff --git a/packages/cli/test/e2e/library.test.ts b/packages/cli/test/e2e/library.test.ts index 3f3b5fa78b..c4cd22f1c8 100644 --- a/packages/cli/test/e2e/library.test.ts +++ b/packages/cli/test/e2e/library.test.ts @@ -88,7 +88,7 @@ async function runNodeScriptOk( describe("library mode (bundled)", () => { beforeAll(async () => { await ensureBundleBuilt(); - }, 60_000); + }, 120_000); // --- Bundle structure --- From 0c230f07baa9fdde7757e82c807833a08e5cbf29 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 11:02:33 +0000 Subject: [PATCH 7/7] fix(build): guard car-extract temp-dir creation inside try/finally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mkdtempSync ran before the try block, so if it threw (ENOSPC, bad TMPDIR, permissions) the exception escaped extractAssetCatalogImages — breaking its documented "never throws" contract and risking a crash in build upload — and the helper's own SEA temp dir leaked because the finally never ran. Move the work-dir creation inside the try and guard its cleanup with an undefined check. Add a unit test that makes mkdtempSync fail and asserts extraction returns null without throwing. --- .../src/lib/build/asset-catalog-extract.ts | 14 +++++-- .../lib/build/asset-catalog-extract.test.ts | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/build/asset-catalog-extract.ts b/packages/cli/src/lib/build/asset-catalog-extract.ts index df1675e6ed..610a2f49d2 100644 --- a/packages/cli/src/lib/build/asset-catalog-extract.ts +++ b/packages/cli/src/lib/build/asset-catalog-extract.ts @@ -145,10 +145,14 @@ export function extractAssetCatalogImages( return null; } - const workDir = mkdtempSync(join(tmpdir(), "sentry-car-")); - const inputPath = join(workDir, "input.car"); - const outputDir = join(workDir, "out"); + // `mkdtempSync` itself can throw (no disk space, bad TMPDIR, permissions), so + // it lives inside the try — otherwise the helper's own temp dir would leak + // and the throw would escape this function's "never throws" contract. + let workDir: string | undefined; try { + workDir = mkdtempSync(join(tmpdir(), "sentry-car-")); + const inputPath = join(workDir, "input.car"); + const outputDir = join(workDir, "out"); writeFileSync(inputPath, content); const stdout = execFileSync(helper.path, [inputPath, outputDir], { encoding: "buffer", @@ -164,7 +168,9 @@ export function extractAssetCatalogImages( log.debug(`Native asset-catalog extraction failed for ${carRelPath}`, err); return null; } finally { - rmSync(workDir, { recursive: true, force: true }); + if (workDir) { + rmSync(workDir, { recursive: true, force: true }); + } if (helper.cleanupDir) { rmSync(helper.cleanupDir, { recursive: true, force: true }); } diff --git a/packages/cli/test/lib/build/asset-catalog-extract.test.ts b/packages/cli/test/lib/build/asset-catalog-extract.test.ts index e2e39caa10..b87539b2ce 100644 --- a/packages/cli/test/lib/build/asset-catalog-extract.test.ts +++ b/packages/cli/test/lib/build/asset-catalog-extract.test.ts @@ -11,6 +11,20 @@ import { describe, expect, test, vi } from "vitest"; import { extractAssetCatalogImages } from "../../../src/lib/build/asset-catalog-extract.js"; +// node:fs is mocked so a test can report the helper present (existsSync) while +// making mkdtempSync fail. Both default to the real implementation; tests set +// mockImplementationOnce to override for a single call. +const { existsSyncMock, mkdtempSyncMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + mkdtempSyncMock: vi.fn(), +})); +vi.mock("node:fs", async (importActual) => { + const actual = await importActual(); + existsSyncMock.mockImplementation(actual.existsSync); + mkdtempSyncMock.mockImplementation(actual.mkdtempSync); + return { ...actual, existsSync: existsSyncMock, mkdtempSync: mkdtempSyncMock }; +}); + describe("extractAssetCatalogImages", () => { test("returns null on non-macOS-arm64 platforms", () => { const platform = vi @@ -56,4 +70,28 @@ describe("extractAssetCatalogImages", () => { arch.mockRestore(); } }); + + test("returns null (never throws) when the temp dir can't be created", () => { + // Simulate a helper being present but mkdtempSync failing (disk full / bad + // TMPDIR). Extraction must still degrade rather than escape as a throw. + const platform = vi + .spyOn(process, "platform", "get") + .mockReturnValue("darwin"); + const arch = vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); + // Helper reported present (dev path), but the work-dir creation fails. + existsSyncMock.mockReturnValueOnce(true); + mkdtempSyncMock.mockImplementationOnce(() => { + throw new Error("ENOSPC: no space left on device"); + }); + try { + let result: unknown; + expect(() => { + result = extractAssetCatalogImages("Assets.car", new Uint8Array([1, 2])); + }).not.toThrow(); + expect(result).toBeNull(); + } finally { + platform.mockRestore(); + arch.mockRestore(); + } + }); });