feat: asset catalog (.xcassets) compilation#219
Open
bytesoverflow wants to merge 4 commits into
Open
Conversation
Users can now declare `assetCatalogs: [Foo.xcassets]` in xtool.yml and the built .app will contain a proper Assets.car plus correct CFBundleIcons Info.plist keys, matching what Xcode's actool produces for the common cases. This unblocks any real iOS app with an App Icon set, color set, or image set from being built end-to-end through xtool, which previously only supported a single loose-PNG `iconPath`. Verified end-to-end on a real iOS device: `xtool dev run --usb` of a fixture app with an AppIcon.appiconset renders the icon on the home screen, and UIImage(named:) resolves arbitrary imagesets at runtime. ## Scope (v1) Supported: - AppIcon.appiconset (iPhone idiom, scales 1x/2x/3x). Any name honoured; the appiconset folder basename becomes CFBundleIconName, so renamed sets like MyIcon.appiconset work. - *.imageset (universal + per-idiom, 1x/2x/3x, light/dark, sRGB/P3) - *.colorset (sRGB + P3, light/dark) - LZFSE-compressed BGRA bitmap renditions via Apple's Compression framework - Partial Info.plist generation (CFBundleIcons including CFBundleIconName, CFBundleIcons~ipad split, top-level CFBundleIconFiles fallback) - Loose appicon PNGs emitted to the bundle root alongside Assets.car so SpringBoard's home-screen path renders the icon even if CoreUI's rendition lookup mismatches our identifier hash - Loud, structured errors on malformed Contents.json, missing PNGs, etc. Out of scope for v1 (throw on encounter): - More than one .xcassets per product - More than one .appiconset per catalog - PDF vectors, SF Symbols custom variants, sticker packs, complications - App-thinning slice manifests, MultiSized Image / iPad subtype-1792 entries - JPEG/HEIC input (PNG only) - iOS 18 tinted/dark home-screen icon layered variants - Incremental builds (full recompile each run; acceptable for v1) - Auto-discovery of catalogs declared via SwiftPM .process resources The legacy `iconPath` field continues to work for users without a catalog. When both are present in the same product, the catalog wins and iconPath is nulled; a Diagnostics warning is emitted. ## Architecture New SwiftPM target XCAssetCompiler (peer of PackLib), pure Swift, depends on tayloraswift/swift-png (Apache-2.0). No Apple-tool shellouts; runs the same on Linux and macOS hosts. Compile happens during planning. Planner.createPlan() invokes XCAssetCompiler.compile() per catalog, producing a CompiledAsset record (carData + emittedFiles + infoPlistAdditions + primaryIconName). Plan.Product carries these records. Packer becomes a dumb copier that drops Assets.car and the loose appicon PNGs into the bundle. Info.plist merge order (planner defaults, user infoPath, catalog additions, packer runtime keys) is documented at the call site. A new Diagnostics actor in XUtils collects warnings; DevCommand drains it to stderr after createPlan returns. ## .car format notes (CoreUI 970, Xcode 26+) The writer was derived against an actool reference and validated on a real device. Six things to know if a future change touches the binary format: 1. **AttributeIDs are not stable across CoreUI versions.** CoreUI 970 / Xcode 26 uses element=1, part=2, appearance=7, dimension2=9, scale=12, localization=13, idiom=15, subtype=16, identifier=17. The public RE writeups (Timac 2018, DBG.RE 2026) lag actual Xcode releases and gave wrong values; treat dumped bytes from `xcrun assetutil --info` and the inspector workflow described in Sources/XCAssetCompiler/CAR/KeyFormat.swift as authoritative. 2. **Magic-word byte order is inconsistent per block.** CTAR, CTSI (appears as 'ISTC' on disk), and kfmt are little-endian multi-char constants (bytes reversed). MLEC, KCBC, COLR, META are written in character order. Get this wrong and the file looks correct but fails at runtime. 3. **A 104-byte TVL section between the CSI header and the bitmap body is mandatory** for raw bitmaps. Without it CoreUI parses the rendition key but cannot materialise the bitmap; assetutil reports AssetType: Unknown. 4. **iOS uses UIAppearanceAny in APPEARANCEKEYS; macOS uses NSAppearanceNameAqua.** CoreUI walks the tree by exact string match; registering only the macOS names produces a catalog where every UIImage(named:) returns nil at runtime even though assetutil parses cleanly. SpringBoard's appicon path doesn't depend on this (it falls back to the loose PNGs), which masks the bug for icon-only catalogs. 5. **actool allocates value blocks before key blocks in BOM trees.** UIImage(named:) returns nil if this ordering is reversed. The catalog still parses with assetutil because the file structure is valid; iOS's runtime walks the leaf assuming value-first IDs. 6. **BITMAPKEYS is required for UIImage(named:) resolution.** The tree uses `isPathInternal = true` with inline u32 keys (NameIdentifiers). Each value is a 52-byte descriptor; the layout differs between appicon and image kinds (Element=85, Part=220 for icons; Element=85, Part=181 for images, with corresponding renditionFlags differences). Without BITMAPKEYS, image lookups return nil even though renditions are present in the file. LZFSE compression via Apple's Compression framework produces byte-identical output to actool for our 120x120 verification case. 180x180 diverges slightly due to encoder-version differences; this is acceptable because CoreUI doesn't gate on byte-equality. Open caveat: `.imageset` assets currently use LZFSE+KCBC like appicons, whereas actool emits compressionType=2 (deepmap2) for images. Device testing shows iOS accepts our LZFSE renditions on the image path post the APPEARANCEKEYS / BITMAPKEYS / tree-ordering fixes; deepmap2 is the canonical format actool produces and may need to be implemented if future iOS releases tighten validation. ## Tests 20 tests in Tests/XCAssetCompilerTests cover: - risk-key-packing: RenditionKey encode/decode round-trip across the cartesian space of v1 attribute values + bytewise layout assertion - risk-icon-name: CFBundleIconName always derives from the .appiconset folder basename (regression guard for the silent-white-icon failure) - risk-vector-flag: bitmap CSI flag word has the vector bit cleared - BOMWriter round-trip parses our own output - ImageRenderer / ColorRenderer / CatalogLoader / AppIconPlist / Diagnostics unit tests - Negative tests for each validation policy failure mode A separate macOS-only assetutil parse gate lands in a follow-up commit.
Compiles a bundled fixture catalog and shells out to xcrun assetutil --info, asserting the parsed JSON has the expected header (StorageVersion 17, Platform ios, SchemaVersion 2) and two Icon Image renditions with correct Idiom / Name / Encoding / BitsPerComponent / ColorModel / Colorspace / PixelWidth / PixelHeight / RenditionName. Auto-skipped where xcrun is unavailable, so Linux runs are unaffected. Wired into the existing build-macos job via a new `swift test --filter XCAssetCompilerTests` step. Catches CoreUI format drift on future Xcode bumps. Replaces the originally planned byte-diff-against-actool gate, which is not viable given LZFSE encoder non-determinism and our deliberate skip of actool's MultiSized Image / subtype-1792 entries. Fixture (Tests/XCAssetCompilerTests/Fixtures/Test.xcassets/) is a minimal self-contained appiconset with 120x120 and 180x180 solid PNGs.
Two issues surfaced via device install of a Linux-built Assets.car: 1. Hard crash on imageset assets whose height was not divisible by 3. The 3-chunk KCBC split was inherited from the appicon reference but was being applied unconditionally; iPad icons (76@2x=152px, 83.5@2x=167px) and any generic imageset PNG with a non-divisible height trapped on the precondition. Now: 3 chunks when height % 3 == 0 (matches actool's reference), 1 chunk otherwise. CoreUI accepts both; the 3-chunk split is mimicry rather than correctness. 2. UIImage(named:) returned non-nil on device but the body wouldn't materialise (rendition resolved but rendered blank). Root cause: on Linux we were writing `compressionType=0 raw BGRA` since Apple's Compression framework is Darwin-only, and CoreUI quietly fails to materialise raw-compressed bitmaps. The MLEC wrapper now always advertises compressionType=3 (LZFSE) and Linux hand-emits an LZFSE "uncompressed block" envelope (`bvx-` + size + raw + `bvx$`) per `lzfse_internal.h`. CoreUI's LZFSE decoder reads this as a passthrough and ends up with the raw pixels intact. Verified end-to-end: Linux-built xtool installs an .app whose home screen icon (loose PNG path) AND in-app UIImage(named:) imageset path both render correctly on a physical iOS device. Also removed stale comments warning that imagesets would not resolve at runtime without a deepmap2 implementation -- empirically they do via the LZFSE+KCBC path verified above.
Previous behaviour: the tree contained only UIAppearanceAny -> 0, but RenditionKey.init(rendition:) packs appearance=1 for any rendition tagged luminosity dark. CoreUI walks APPEARANCEKEYS by exact name-string match to resolve the appearance slot in a rendition key; with no row for appearance=1, every dark-variant lookup in a catalog with light+dark imageset entries silently returned nil from UIImage(named:). Now registers both UIAppearanceAny -> 0 and UIAppearanceDark -> 1, and the header doc spells out the invariant so the next regression is harder to introduce: every numeric ID that can appear in a rendition key needs a row, or lookups silently fail. Verified on device with a SampleImage.imageset declaring both a default and a luminosity-dark variant; both render correctly under the corresponding colour scheme.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
.xcassetscompiler (Sources/XCAssetCompiler/**) that emits an actool-compatibleAssets.carplus theCFBundleIconspartial Info.plist, wired intoxtool dev buildvia a new optionalassetCatalogs:field inxtool.ymlAppIcon.appiconset,*.imageset(light/dark, sRGB/P3), and*.colorset; renditions go out as LZFSE-framed BGRA8 bitmaps. On Linux, where Apple's Compression framework is unavailable, the encoder emits a valid LZFSE "uncompressed block" envelope (bvx-+ size + raw +bvx$) so the resulting.carparses on device with raw pixels intactDiagnosticsactor inXUtils, drained to stderr by the CLI.iconPathcontinues to work standalone; when bothiconPathand a catalogAppIconare present, the catalog wins with a warningFormat provenance
Clean-room implementation against the public reverse-engineering writeups by Timac (2018) and dbg.re (2026), cross-checked against actool output from Xcode 26 / CoreUI 970 / StorageVersion 17. Every magic constant, header size, and attribute order is cited inline against the reference. The macOS CI gate (
AssetutilParseTests) shells out toxcrun assetutil --infoon the compiled fixture and asserts the storage version, schema version, encoding, colorspace, idiom, and per-rendition dimensions match actool's expectations.Deferred
.xcassetsper product (schema throws on >1).appiconsetper catalog (compiler throws on >1).process(...)rulesKnown gaps
assetCatalogs:field or device-install procedurerenditionFlagsconstants; the assetutil CI gate covers structural driftIdiomstrict-decodes from JSON; unknown idioms (e.g.vision) surface asmalformedContentsJSONrather than a structured "unsupported idiom" errorImageRenderer.decodeBGRAPremultipliedusesmissingReferencedFilefor PNG-decode failures (one error case shared with "file does not exist")CARHeaderdefault UUID is all-zero