What it takes to ship a small app to the App Store
The app worked. Getting it into the store took several more rounds than expected, and almost none of the obstacles were about the app. These are the specific ones, written down mostly so the next person can skip them.
Context: this is about AddToPDF, a small PDF editor we make, built with Tauri 2 and shipped to both the Mac App Store and the iOS App Store. The details below are Apple-and-Tauri specific, but the shape of the problem — the last ten per cent taking longer than the first ninety — is not.
The first surprise: an offline app needs a network entitlement
The app makes no network requests. That is the entire point of it. So the sandbox entitlements listed exactly what it needed: file access for documents the user picks, and nothing else.
The result was a window that opened and rendered nothing at all. White, empty, no error.
The cause took a while to find and is worth stating plainly: a web-view-based app serves its own content through a custom URL scheme, and the web view's content process talks to that scheme handler over what is, structurally, a local network connection. Sandboxed, that handshake fails, and the view never leaves about:blank. Adding com.apple.security.network.client fixed it — an outgoing-network entitlement, on an app whose selling point is that it never uses the network.
Nothing about that is discoverable from the failure. The app does not log an error; it just shows you nothing.
The second surprise: you cannot run the build you are shipping
This one shapes everything else. A Mac App Store build is signed with a distribution certificate and a store provisioning profile, and Gatekeeper refuses to launch it locally — by design. So the artefact you upload is one you have never seen run.
Which means the blank window above was not found in a debugger. It was found by uploading a build, waiting for processing, installing through TestFlight, and watching it fail. Every hypothesis costs a full upload cycle. It took four.
If you take one thing from this: budget for the fact that your first real execution of the shipping build happens after upload. Plan the diagnostics you will want before you need them, because adding a log line is a round trip.
Rejections that only exist after upload
Two rejections arrived from the pipeline rather than from a human reviewer, both after a successful build:
- Apple-silicon-only binaries are refused unless the deployment target is recent enough — and a target that recent excludes every Intel Mac. The practical answer is a universal binary, which is one build flag and no code change, but it is not something you discover until the upload bounces.
- An extended attribute broke a signature. The provisioning profile had been downloaded through a browser, so it carried a quarantine flag. Embedded in the bundle, that flag failed validation with a code that means nothing on first reading. Stripping extended attributes before signing fixed it permanently.
Both are trivial once known and completely opaque beforehand.
Sandbox breaks things that are not obviously file access
Printing worked perfectly in development and failed in the store build. The reason: the convenient way to print was to write a temporary file and ask the system to open it in the default PDF application — and a sandboxed process is not allowed to launch other applications that way. The file was written successfully; only the "open it" step failed.
The fix was to stop shelling out and print natively through the platform's own printing APIs, presented as a sheet on the window. Better result, more code.
The generalisable version: anything that works by asking another process to do something is a sandbox risk, even when it looks like ordinary local behaviour. The same trap reappeared later for opening a support URL — routing it through the system's URL-opening API is fine; spawning the command-line tool is not.
There is a related trap in the permission system itself: reading and writing files needed two different kinds of permission — one granting the capability, one granting the path scope — and having only the second produced a runtime error that reads like the first was missing.
Metadata is a system too, and it validates on the server
The store listing turned out to be a comparable amount of work to the app.
The listing covers 39 locales. The local validation tool passed cleanly; the real upload then rejected five fields for exceeding length limits, because those limits are enforced server-side only. A related trap: count characters, not bytes — non-Latin scripts look wildly over-limit if you measure them wrong.
Two more that cost real time:
- Uploading a build does not attach it to a version. Those are separate operations, and nothing warns you. The submission simply stays un-submittable until you notice.
- Search keywords freeze at submission. Anything wrong in them is wrong until the next release — which matters more than it sounds, because it is easy to inherit keywords across platforms and end up advertising features one of the builds does not have.
The second platform is not free
Sharing a codebase across Mac and iPhone sounds like most of the work is done. The engine did port cleanly. The platform edges did not.
- The standard file-reading API hung — not failed, hung — on files handed to the app by the system on iOS. It needed a small native function that simply reads the bytes.
- Editing a document in place is effectively over on modern iOS. The workable pattern is: receive a copy, edit it, hand it back out through the share sheet. Every PDF app on the platform works this way, and users read it as normal.
- The archive silently ballooned to 291 MB because static libraries meant for linking were also being copied in as resources. One build-phase setting; enormous difference.
- The upload tool failed halfway and then hung indefinitely after a transient server error, reporting success for work it had not finished. The lesson is to verify uploads against the server rather than trusting the tool's own output.
The touch interface itself was a rebuild rather than a port. The document engine is shared; almost every piece of interface is not, because a mouse and a thumb want different things.
What this adds up to
None of the above is a complaint. Most of these rules exist for defensible reasons, and the sandbox in particular is a large part of why buying software from the store is safe. But it is worth being honest about the shape of the work, because "the app works" and "the app is purchasable" are separated by a surprising amount of it — and almost all of that distance is invisible from the outside.
The part that genuinely surprised us was how much of the difficulty was unobservable locally: a build you cannot run, limits enforced only on a server, failures that produce a blank window instead of an error. Those are the ones that turn an afternoon into a fortnight.
The upside of doing it on a small app is that the surface area stays small enough to get through. That is the same argument as the missing middle, from the other direction: scope is what makes any of this survivable for one person.
AddToPDF — the app we make
A deliberately small PDF editor for Mac, iPhone and iPad: add text, sign, fill forms, organise pages. Fully offline, no account, one-time purchase covering all three devices. It does not edit existing body text or do OCR.
Download on the App StoreRelated
The missing middle
Why software you can simply buy, once, nearly disappeared — and what can fill the gap.
Read the note ›What "free" actually costs
Six business models behind free software, and how to tell which one you are using.
Read the note ›Mac PDF editors: an honest comparison
Eight tools, what each is genuinely best at — including where ours loses.
Read the comparison ›