Submitted
“The binary is done. The rest is not up to me.”
App Store Connect shows “Waiting for Review.” A status badge, yellow, in a browser tab. Months of work, reduced to a queue position.
There is nothing left to fix. Nothing left to optimize. The app is as good as I can make it, and now someone at Apple will decide if it meets their guidelines. I made a cup of coffee.
The Gap
The binary was done weeks before it shipped. What filled the gap was not code.
First submission, first rejection. Then a second. Then four more. Six reviews across four versions, spread over three weeks. Each fix took a day or less. The review cycle took the rest. The app was ready for the first submission. The process was not.
The Wrong Diagnosis
One rejection kept coming back: the tip jar appeared blank. Four reviews, four identical messages. “In-app purchase products could not be found in the submitted binary.”
My initial explanation was plausible, internally consistent, and wrong. I assumed the products were in “Waiting for Review” status and that Apple’s sandbox simply could not see them yet. Apple’s replies did not correct this. The same templated suggestions came back each time: ensure the products are active, verify your StoreKit implementation, resubmit.
Meanwhile, the tip jar worked perfectly on my machine. Every time. The Xcode scheme included a local StoreKit configuration file that intercepts all Product.products(for:) calls and serves products from a local JSON, bypassing Apple’s servers entirely. The testing equivalent of a smoke detector with dead batteries: everything looks fine until the house is on fire and somebody else notices first.
The real cause was a Paid Apps Agreement on a Business page in App Store Connect I did not know existed. The agreement requires banking information, tax forms, and EU Digital Services Act compliance documentation. None of that was completed before the first submission. No warning at submission time. No validation. The IAP products showed “Ready to Submit” and looked correctly configured. Apple named the agreement only after a direct question on the fifth interaction, twelve days in.
I did not know what I did not know, and the system does not hold your hand.
Lessons
The toolchain is harder than the language. Swift’s syntax and basic patterns are learnable in a few weeks if you have a background in typed languages. Xcode, signing certificates, entitlements, the sandbox, provisioning profiles, notarization, the Paid Apps Agreement: these take longer. The language is the easy part. The platform is the course.
The compiler is on your side. Swift’s strict concurrency checking produces errors that feel hostile until you understand what they are saying. Not every warning maps to a real data race, but in this project, the majority pointed to genuine concurrency bugs. An error about actor isolation is not bureaucracy: it is the compiler flagging a code path it cannot prove safe, and proving it safe is your job. Treat compiler errors as information, not obstacles. Apple’s rejection messages, on the other hand, are not on your side. Four identical responses, twelve days, and the root cause was named only after a direct question.
Apple’s documentation is good when it exists. When it does not exist, you read source headers and WWDC session transcripts from three years ago. API reference stays current because it is generated from headers. Conceptual guides and migration docs age faster.
Performance requires understanding the platform, not just the language. Web development abstracts the underlying system behind HTTP, JSON, and framework conventions. Native macOS does not. getattrlistbulk, a Darwin syscall that fetches file attributes in bulk per directory, versus FileManager.enumerator, which asks the kernel one file at a time. The getattrlistbulk scanner was about 1.7 times faster. That gap is invisible without measurement.
The platform runs deeper than the API surface. Dispatch queues and the assertions that catch you calling blocking work on the main thread. The difference between resident memory and virtual memory. These are not exotic topics. They are the platform.
Tools give you information, not judgment. I used GitHub Copilot throughout the project. For architecture decisions, less so: it does not know the project’s constraints. The crashes, the memory optimization, the getattrlistbulk discovery, the Paid Apps Agreement: those required reading documentation, running Instruments, and understanding the system. Copilot can help write the code once you know what to write. Knowing what to write is the harder part, and always has been.
What I Would Do Differently
Start with getattrlistbulk. I built the FileManager scanner first because it was obvious, and that implementation taught me enough about the problem to make the rewrite legible. But the performance gap was large enough that I wish I had explored the lower-level API sooner.
Add PerfLogWriter earlier. The performance logging infrastructure that helped find the main thread stalls was added after the stalls showed up. The stalls told me where to measure, but having the tooling ready would have shortened the diagnosis.
Remove the IAPs and ship. The tip jar blocked every other fix from reaching production for twelve days. The crash fix, the purpose string fix, the clean submission: all held hostage by an optional feature with an administrative root cause. Ship the working app, sort out the business agreement separately.
Two things I would not change: localization from the start, and writing the Architecture Decision Records (ADRs). Extracting strings for localization after the fact is painful. Keeping them externalized as you write new UI code is just a discipline. The actual translations came later, but the infrastructure was there from the start. The app shipped in 11 languages. That would not have happened as an afterthought. The ADRs are 41 documents covering choices that were not obvious at the time. During the rejection debugging, I could look up exactly why NSWorkspace.openApplication was called the way it was, and what alternatives had been considered. That is faster than reading git blame and inferring intent.
The Number That Still Surprises Me
5.2 million nodes on a full disk scan. That is what a well-used developer Mac actually contains. Files, directories, symlinks, all enumerated, all sized, all laid out in a treemap in about a minute (Debug build, M3 Pro). The order of magnitude is real.
When I started this project, I did not know if that was achievable. The answer required choosing the right syscall, understanding why FileManager was slow, and measuring everything.
The project by the numbers:
| Metric | Value |
|---|---|
| Commits | ~353 |
| ADRs | 41 |
| Languages | 11 |
| Submission cycles | 6 |
Scan speedup (getattrlistbulk vs FileManager) | ~1.7x |
| Memory (before) | 1.2 GB |
| Memory (after) | 469 MB |
| Duplicate detection | ~1.3 to ~397 groups/s |
The app is in the queue. The disk is no longer full. Sometimes the simplest problem statement produces the most interesting engineering.
References
- Apple: App Store product page guidelines
- Apple: Sign and update agreements
- GitHub: GitHub Copilot
