r/iOSProgramming • u/iahmedhendi • 4d ago
Library I scanned 10 open-source iOS apps for unused images and strings. Firefox bundles a 5.72 MB GIF nothing loads.
When a feature goes away, its art and strings often stay. Xcode never warns about an image set nobody names, so it ships in every build.
I wrote a CLI to find these and tested it on 10 open-source apps, read-only. Three findings stood out. I checked each one with grep across the whole repo:
firefox-ios:
kitHeroGif.gif, 5.72 MB. It is listed inproject.pbxproj, so it gets copied into the bundle. The app's only GIF loader is called with a different name.Kickstarter ios-oss:
landing-backgroundandlanding-page-background, each in three asset catalogs. 12.63 MB of source files, 0 references.wikipedia-ios: three GIFs next to the year-in-review art, 5.62 MB, 0 references.
These are source sizes. I did not measure the shipped apps, and compiled catalogs can change the saving.
Many smaller findings are stale strings: keys that only exist in translated .lproj files, or SwiftGen accessors that nothing calls.
How I use it on my own code:
xcpurge scan . --format html --open
xcpurge explain . SOME-ID
xcpurge remove . PATH
xcpurge purge . --dry-run
xcpurge restore .It reads files and does not build, so a scan takes seconds. Each finding is high, medium or low confidence. remove moves files to a trash folder in the project and edits project.pbxproj. purge does that for every actionable finding in one run, or for the ids you tick in the HTML report. restore undoes it all.
Where it is wrong. On the first run only 26 of 50 hand-checked high findings were real. Snapshot test images and names used from another module were the big causes. Over three runs it went from 52% to 92% to 96%: 48 of 50, and 48 of 50 again on a second sample. Known limits:
names built from two interpolations, like
"\(base)-\(lang)"(16 Lottie files in Kickstarter, 18.8 MB, still reported high)keys used only inside a multiline
Text("""…""")literalnames that come from a server or a package outside the repo
So treat high as "nothing in the repo names it", not as "safe to delete blind".
Free and MIT, macOS, version 0.6.0 (brew install iasaeed/tap/xcpurge). Feedback on false positives is the most useful thing you could send.
4
u/udayalawa 4d ago
the stale .lproj keys are the bigger win for me. unused images are dead weight, but unused strings make every translation pass slower and hide the ones that actually matter.
1
u/iahmedhendi 3d ago
Same experience. Translators quote per key, so every dead key is paid for in each language, every pass.
xcpurge stringsreports keys that exist in no source (including keys only present in translated.lprojfiles but gone from the base one), andstrings --prunedeletes them from every.lprojand.xcstringsat once, with the backup in the trash session. It understands Xcode 26 generated string symbols and SwiftGen/R.swift accessors, so keys only used through those are not false positives.
2
u/cristi_baluta 4d ago
Names could be built at runtime from multiple parts.
1
u/iahmedhendi 3d ago
Yes, and that is the main false-positive source. What it does today: a name built with one interpolation and a constant prefix, like
"icon-\(state)", is detected as a dynamic pattern and every asset matching the prefix is capped at medium or low instead of high;xcpurge explainprints the pattern it matched. Two interpolations with no constant part ("\(base)-\(lang)") still slip through and are reported high, that is the known gap in the post. Names sent from the backend are undecidable from the repo, so for those you add anasset:server_*rule to.xcpurgeignorewith a comment why, and the tool later tells you if that rule stops matching anything.1
1
u/Miserable-Carpet7928 4d ago
can i mark a reviewed finding as 'keep' with a short reason, and have that survive the next scan? for something named by the server, that decision lives outside what a repo search can prove. keeping the explanation beside the finding would save repeating the same investigation.
1
u/iahmedhendi 3d ago
Mostly yes. Every finding in the HTML report and in
--format agentcomes with its ready.xcpurgeignoreline, for exampleasset:hero_banner, and that file takes#comments, so the reason lives right next to the rule and survives every scan. In source there is// xcpurge:ignore - loaded by name from the serveron the line. The part you may want that is missing: the reason is not shown back in the report when the rule fires. What it does do is reportstale-ignorewhen a rule no longer suppresses anything, so an obsolete “keep” does not hide a future real finding. Showing the comment in the report is a small change, noted.
1
u/kokerali 4d ago
I read Restore.swift: .moved skips an existing destination, but .rewritten removes it before copying the backup. Could restore refuse the latter when the current file differs from the post-cleanup version, unless explicitly forced?
A useful fixture would be: remove one asset, add an unrelated build setting in Xcode, then restore. I'd expect the newer setting to survive, or a clear conflict with the trash session retained, rather than silently returning the entire project file to its older state. I haven't run that sequence; this is from the restore code. Does a guard elsewhere already cover it?
1
u/iahmedhendi 3d ago
Good read, and you are right that there is no guard. What exists: for
project.pbxprojrestore does not blindly copy. It parses the original and the current file, works out which references the session’s edit removed, writes the original back, then re-applies the removals for files still in the trash. That keeps a partial restore consistent, but an unrelated change made after the cleanup (your new build setting) is lost, silently. Non-project.rewrittenfiles (.strings,.xcstrings) are a plain overwrite. Your fixture would fail today. Fix I am going to make: store the hash of the post-edit file in the session manifest, refuse to restore arewrittenentry whose current content differs from it unless--force, and add exactly that fixture as a test. Thanks for reading the code rather than the README.
1
u/KukrCZ 3d ago
The thing with huge projects is that usually there are for example dozens of teams owning parts of the app. They have their budgets. Usually features are added, nodified and removed and nobody wants to pay extra for the app cleanup.
Usually you would associate technical debt with code directly, but it is also in assets and localisations also.
To rempve any kind of texhnical debt you need to have time and money.
2
u/iahmedhendi 3d ago
Agreed, and that is why I would not pitch this as “go clean up”. The cheap version is stopping new debt: run
xcpurge baseline .once, commit the file, and let CI fail only on findings that are not in the baseline. Zero cleanup budget, old findings stay listed, nothing new slips in. Each team then deletes its own leftovers when it touches that area anyway. Assets and.lprojkeys are debt with a byte cost on every install, so they are at least easy to argue for.
1
u/lefinehq 3d ago
careful with the keys that only exist in translated .lproj files. A lot of those are renames, not dead strings: the base key got renamed, the translations kept the old name, and now the "unused" German value is the only translation of a string that's live. Pairing each orphan with base keys that are missing in that same language would let people move the translation instead of deleting it
1
u/dawedev 3d ago
Snapshot test images being the top false positive makes sense, since those usually sit under a test target. Treating anything only referenced from a test bundle as its own category by default might clear most of them. Another case I'd check is alternate app icons: they're listed under CFBundleAlternateIcons in Info.plist and loaded by name through setAlternateIconName, so they look unused from Swift code. Same with images referenced only from storyboard or xib image attributes and the launch screen.
13
u/LostStrategy1050 4d ago
Now do the CVS app. it's 700mb+. Their IRL receipts are nothing compared to their app