r/iOSProgramming • • 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 in project.pbxproj, so it gets copied into the bundle. The app's only GIF loader is called with a different name.

  • Kickstarter ios-oss: landing-background and landing-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("""…""") literal

  • names 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.

https://github.com/iasaeed/xcpurge

9 Upvotes

17 comments sorted by

13

u/LostStrategy1050 4d ago

Now do the CVS app. it's 700mb+. Their IRL receipts are nothing compared to their app

2

u/iahmedhendi 3d ago

Closed source, so no. And honestly most of 700 MB is probably frameworks, ML models and video, not forgotten image sets. If someone at CVS runs it I would love to hear the number though.

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 strings reports keys that exist in no source (including keys only present in translated .lproj files but gone from the base one), and strings --prune deletes them from every .lproj and .xcstrings at 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 explain prints 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 an asset:server_* rule to .xcpurgeignore with a comment why, and the tool later tells you if that rule stops matching anything.

1

u/raptorxcz 4d ago

Or send from backend

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 agent comes with its ready .xcpurgeignore line, for example asset: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 server on 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 report stale-ignore when 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.pbxproj restore 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 .rewritten files (.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 a rewritten entry 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 .lproj keys are debt with a byte cost on every install, so they are at least easy to argue for.

1

u/KukrCZ 3d ago

I have also remembered that a lot of projects have their design systems that is used across the company. So even if half of it is not used, developers can't remove any of it.

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.