Skip to content

feat: asset catalog (.xcassets) compilation#219

Open
bytesoverflow wants to merge 4 commits into
xtool-org:mainfrom
bytesoverflow:feat/xcassets-scaffolding
Open

feat: asset catalog (.xcassets) compilation#219
bytesoverflow wants to merge 4 commits into
xtool-org:mainfrom
bytesoverflow:feat/xcassets-scaffolding

Conversation

@bytesoverflow
Copy link
Copy Markdown

Summary

  • Adds a pure-Swift, Linux-compatible .xcassets compiler (Sources/XCAssetCompiler/**) that emits an actool-compatible Assets.car plus the CFBundleIcons partial Info.plist, wired into xtool dev build via a new optional assetCatalogs: field in xtool.yml
  • Covers AppIcon.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 .car parses on device with raw pixels intact
  • Compile happens during planning, packer becomes a dumb copier. Diagnostics surface via a new Diagnostics actor in XUtils, drained to stderr by the CLI. iconPath continues to work standalone; when both iconPath and a catalog AppIcon are present, the catalog wins with a warning

Format 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 to xcrun assetutil --info on the compiled fixture and asserts the storage version, schema version, encoding, colorspace, idiom, and per-rendition dimensions match actool's expectations.

Deferred

  • PDF vectors, SF Symbols custom variants, sticker packs, complication sets, AR groups
  • LZFSE-compressed bitmaps on Linux (passthrough envelope only)
  • deepmap / LZVN compression formats; app-thinning slice manifests
  • JPEG/HEIC input (PNG only)
  • iOS 18 tinted/dark home-screen icon layered variants
  • Per-locale image variants
  • Multiple .xcassets per product (schema throws on >1)
  • Multiple .appiconset per catalog (compiler throws on >1)
  • App-extension catalogs
  • Incremental builds (recompile on every build)
  • Auto-discovery of catalogs declared via SwiftPM .process(...) rules

Known gaps

  • No DocC documentation for the new assetCatalogs: field or device-install procedure
  • No dedicated unit test pinning the bitmap renditionFlags constants; the assetutil CI gate covers structural drift
  • Idiom strict-decodes from JSON; unknown idioms (e.g. vision) surface as malformedContentsJSON rather than a structured "unsupported idiom" error
  • ImageRenderer.decodeBGRAPremultiplied uses missingReferencedFile for PNG-decode failures (one error case shared with "file does not exist")
  • CARHeader default UUID is all-zero

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant