Table of Contents
- The problem
- Design decisions and alternatives
- Three sizes, kept apart
- No dependencies in the library
- Output for people and for CI
- Input is untrusted
- A real bug: Swift strings are not file paths
- Usage
- Limits
The problem
The usual answer to "what made the app bigger?" is to unzip the .ipa and sort files by size. That answers part of it. A framework is a folder of many files, a language is a set of .lproj folders spread across the app and its frameworks, a universal binary carries a slice for every architecture, and an asset catalog is one opaque Assets.car. Apple's own numbers (Xcode's App Thinning Size Report, App Store Connect) need Apple's build pipeline and a signed build, which a pull request check on a Linux runner does not have.
sizeledger is a command-line tool and a Swift library for that gap. Point it at an .ipa, an .app or an .xcarchive and it reports where the bytes are: by category (main executable, frameworks, app extensions, App Clips, watch app, localizations, asset catalogs, code signature, other resources), by framework and resource, by .lproj language and by CPU architecture slice. It reads the files only. It does not build, run, upload or change anything, and it gives the same report on macOS and Linux.
The repository opened on 15 October 2026 and 0.1.0 shipped the same day. Ten patch releases followed by 21 October, the current one being 0.1.10; most of them harden the readers against crafted input.
Design decisions and alternatives
Three sizes, kept apart
The key decision is what not to claim. "App size" means at least three things, and sizeledger names each one:
- Total (
bytes): the sizes of the files added up, what a file manager shows. - Install size, estimate (
installBytes): every file rounded up to whole 4 KiB blocks, the way APFS stores it. In the source this isInstallSize.estimate, withblockSizeset to 4,096. - Compressed in the
.ipa(compressedBytes): what the bundle's files take inside the archive, for.ipainput only.
None of them is the App Store's download or install size. The App Store thins the app per device (one architecture slice, only the asset images for that device), encrypts the binaries and compresses the download. A formula that guesses the store number from the upload would be off by an unknown amount, so the report calls the install size an estimate and adds notes when the input is a simulator build or holds universal binaries, the two most common reasons a device sees something smaller.
The estimate is checked rather than assumed: the tests unpack the two SampleShop fixture apps and compare against du. On a Mac (Apple silicon, macOS 27.0.1, APFS) the install estimate equals du -sk exactly; in the Linux container (swift:6.4.1-noble, aarch64) the total equals du -b --apparent-size. The tests fail above 1% on both.
No dependencies in the library
SizeLedgerKit has no package dependencies. The ZIP reader (with ZIP64), the DEFLATE decoder and the Mach-O parser (thin, fat and fat64) are written in Swift inside the library. The alternative, shelling out to unzip, lipo and otool, ties the tool to Xcode, while the point is that the measurement runs anywhere, including a Linux CI job that receives the .ipa from a macOS build job. The DEFLATE decoder is tested against zlib's output for stored, fixed and dynamic blocks, and the Mach-O parser on thin, 32-bit, fat and fat64 files. Only the command-line target depends on swift-argument-parser.
Output for people and for CI
The default output is a terminal table. --format markdown writes GitHub Markdown for a pull request comment or a job summary, and --format json writes a report with a stable key order, so it can be committed, queried with jq and compared with diff. Until 1.0 the JSON layout carries schemaVersion 0 and may change in a minor version; the changelog says when it does.
Input is untrusted
An .ipa is a file someone hands you, so sizeledger treats it as hostile. Since 0.1.4 it refuses inputs past limits on file count, path depth, declared uncompressed bytes and compression ratio, set with --max-files, --max-depth, --max-bytes and --max-ratio or, in the library, an AnalysisLimits value. In 0.1.0, a 1 MB crafted .ipa whose fat header pointed a slice 1 GB into a DEFLATE entry made the tool hold 3.77 GB in memory. Headers are now read as ranges of at most 4 KiB, and decompression keeps only the 32 KiB window that DEFLATE back-references need; the same file then took 11.5 MB on a Mac (Apple silicon) (PR #27).
A real bug: Swift strings are not file paths
The most instructive bug was not about sizes at all. Swift's String compares canonically equivalent text as equal and splits by Character, a grapheme cluster. An unzip tool and a file system do neither. Until 0.1.7, sizeledger handled entry paths as Strings, and PR #52 lists what that broke:
Payload/G.app/../\u{301}evilwas accepted although the README says a..component is refused. The combining mark U+0301 joins the/before it into oneCharacter, sosplit(separator: "/")never produced...Payload/G.app/\u{301}big.bin, a 5 MB file, failedhasPrefix("Payload/G.app/"), landed among the files outside the bundle and left the totals.café.binin NFC and in NFD were oneStringdictionary key, so the second file replaced the first and its 5 MB dropped out of the totals without a note.- A
/followed by a combining mark did not count toward--max-depth.
The fix (commit f77172f, "fix(ipa): split and compare entry paths on their bytes") moved every decision about which files exist, which folder they sit in and how deep they are onto bytes. The internal BundlePath type splits, prefixes and compares on utf8 views, and a PathKey type keys dictionaries and sets by bytes. Eight new tests in PathBytesTests failed on main before the change.
The first fix was not the whole story. In 0.1.8 (PR #55), an .ipa entry whose name is not valid UTF-8 was still decoded as Latin-1 and then identified by that text, so caf\xE9.bin and the UTF-8 café.bin became one path: an archive that unzip -l lists as 4 files and 5,000,179 B was reported as 3 files and 179 B. Identity now comes from the bytes the archive stores, PathKey(bytes:), while the shown name is still the Latin-1 reading, with a note in the report that two rows can read the same. The lesson: a path is a byte sequence shown as text, and a comparison through String equality, hasPrefix or a String-keyed dictionary decides something the file system never did.
Usage
Install with Homebrew on macOS or Linux, or download a release binary for macOS (universal) or Linux (x86_64, aarch64) and check it against SHA256SUMS:
1brew install muhittincamdali/tap/sizeledgerThen measure an exported .ipa, the .app from a build or an .xcarchive:
1sizeledger analyze path/to/MyApp.ipaOn the SampleShop 2.0 fixture this prints 8.75 MB in 21 files, an 8.80 MB install estimate and 2.72 MB compressed. For CI:
1# Keep the report for later2sizeledger analyze build/MyApp.ipa --out size/main.json3 4# Markdown in a GitHub Actions job summary5sizeledger analyze build/MyApp.xcarchive --format markdown >> "$GITHUB_STEP_SUMMARY"Since 0.1.10, --out does not replace an existing file: it ends with exit status 2 unless --force is given. Exit status 0 means the report was made, 2 that the input could not be read or the report could not be written, and 64 that the options were invalid.
The library gives the same report to your own tools. Add .package(url: "https://github.com/muhittincamdali/sizeledger", from: "0.1.10") and the product SizeLedgerKit. The example in Examples/LibraryUsage, which the Linux workflow builds and runs on every pull request and every push to main, starts like this:
1let report: SizeReport2do {3 report = try SizeLedger.analyze(URL(fileURLWithPath: path))4} catch {5 print("error: \(Sanitize.text(String(describing: error)))")6 exit(2)7}8 9let totals = report.totals10print("\(Sanitize.text(report.app.name ?? report.input.bundle)): \(totals.files) files")11print(" bytes: \(ByteFormat.string(totals.bytes))")12print(" install size (estimate): \(ByteFormat.string(totals.installBytes))")Names, paths and notes come from the app, so they go through Sanitize.text, which replaces control characters, bidirectional overrides and characters that draw nothing.
Limits
- Sizes are local measurements. The App Store download and install sizes depend on thinning, encryption and compression at Apple; sizeledger reports an estimate and says so.
- Asset catalogs are counted as files. The renditions inside
Assets.carare not listed yet (planned for 0.8, throughassetutilon macOS). - Swift metadata is the size of the
__swift5_*sections, a lower bound, because some metadata lives in__const. - A macOS
.appwith aContents/folder is measured, but its files land under "Other resources". - An
.ipawith more than one app, encrypted entries, compression other than stored and DEFLATE, or a..path component is refused with a message, not guessed at. - Within the default limits a crafted input can still cost time: the slowest one found took 15 to 24 s on a busy Mac (Apple silicon). A lower
--max-byteslowers that bound. - Only
analyzeexists today.diff(planned for 0.2),budgetrules with a GitHub Action (planned for 0.3), an HTML treemap (planned for 0.8), a frozen JSON schema (planned for 1.0) and Android support (planned for 1.1) are not in the tool yet. Untilbudgetexists, a CI limit check is onejqline on.totals.installBytes.
CI runs the 159 tests on macOS and on Linux (x86_64 and arm64); one needs a non-UTF-8 file name, which APFS refuses, so it runs on Linux only.
Source code: github.com/muhittincamdali/sizeledger
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy
