diff --git a/apps/cli-docs/src/fragments/commands/build.md b/apps/cli-docs/src/fragments/commands/build.md index ffa76e9143..f8a2cc1517 100644 --- a/apps/cli-docs/src/fragments/commands/build.md +++ b/apps/cli-docs/src/fragments/commands/build.md @@ -34,10 +34,13 @@ 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. 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..bd659648d7 --- /dev/null +++ b/packages/cli/native/car-extract/main.swift @@ -0,0 +1,171 @@ +// 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) + +// 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 { + 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 170a296d9f..74e6e71e72 100644 --- a/packages/cli/src/commands/build/upload.ts +++ b/packages/cli/src/commands/build/upload.ts @@ -155,8 +155,11 @@ 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). 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..610a2f49d2 --- /dev/null +++ b/packages/cli/src/lib/build/asset-catalog-extract.ts @@ -0,0 +1,178 @@ +/** + * 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; + } + + // `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", + 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 { + if (workDir) { + rmSync(workDir, { recursive: true, force: true }); + } + if (helper.cleanupDir) { + rmSync(helper.cleanupDir, { recursive: true, force: true }); + } + } +} 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..4f90d6e4ca --- /dev/null +++ b/packages/cli/src/lib/build/asset-catalog.ts @@ -0,0 +1,243 @@ +/** + * 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); + } + } + // 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); + 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..b14ea713cf 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,98 @@ 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"; +import { + type ExtractedImage, + extractAssetCatalogImages, +} from "./asset-catalog-extract.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"); +} + +/** 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}`; +} + +/** 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[], + 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`); +} + +/** + * Build the ParsedAssets entries for an `Assets.car`: a size/geometry manifest + * (JSON) plus, on macOS arm64, the decoded per-rendition PNGs. + * + * 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 buildParsedAssets( + archiveRoot: string, + carRelPath: string, + content: Uint8Array +): ParsedAssetEntry[] | null { + let assets: AssetCatalogEntry[]; + try { + 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. */ export type BuildFormat = "apk" | "aab" | "ipa" | "xcarchive"; @@ -310,9 +401,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). 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. @@ -334,6 +428,14 @@ export async function normalizeBuildDirectory( entry.content, { level: 0, mtime: FIXED_MTIME, os: ZIP_OS_UNIX, attrs: entry.attrs }, ]; + if (isAssetCatalogPath(entry.relPath)) { + const parsed = buildParsedAssets(dirName, entry.relPath, entry.content); + if (parsed) { + for (const asset of parsed) { + entries[asset.path] = [asset.content, ENTRY_OPTIONS]; + } + } + } } entries[METADATA_FILENAME] = [ strToU8(buildMetadataFile(plugin)), @@ -425,10 +527,16 @@ 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 parsed = buildParsedAssets(archiveDir, productRelPath, bytes); + if (parsed) { + for (const asset of parsed) { + archiveEntries.push([asset.path, asset.content]); + } + } + } } archiveEntries.push([ `${archiveDir}/Info.plist`, 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 --- 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..b87539b2ce --- /dev/null +++ b/packages/cli/test/lib/build/asset-catalog-extract.test.ts @@ -0,0 +1,97 @@ +/** + * 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"; + +// 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 + .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(); + } + }); + + 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(); + } + }); +}); 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-extract.integration.test.ts b/packages/cli/test/lib/build/car-extract.integration.test.ts new file mode 100644 index 0000000000..c6fb2c4b76 --- /dev/null +++ b/packages/cli/test/lib/build/car-extract.integration.test.ts @@ -0,0 +1,165 @@ +/** + * 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 { + // 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 { + 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( + "xcrun", + [ + "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(() => { + // 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 { + const out = buildCarExtract(); + helper = out === null ? null : join(PKG_ROOT, out); + } 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); + }); +}); 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..f18f42715c 100644 --- a/packages/cli/test/lib/build/index.test.ts +++ b/packages/cli/test/lib/build/index.test.ts @@ -15,7 +15,9 @@ 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, extractIpaAppName, @@ -171,6 +173,98 @@ 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); + }); + + 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)", @@ -297,10 +391,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 +431,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(""),