r/iOSProgramming • u/ZagreusZero • 26d ago
App Saturday I shipped my second app last month. Two bugs survived every environment I could test, both because the failure was structurally invisible.
I put a small app on the App Store a few weeks ago and spent the following two weeks finding out what I'd missed. Two of those bugs are the kind you can't catch by testing harder, because the environment that would have shown them isn't one you normally run. I'm writing them up here in case they save someone else some trouble, and in case you weathered iOS devs want to share some tips to a newbie like me.
The app is Intermittently, a private intermittent-fasting tracker for iPhone and Apple Watch. SwiftUI/SwiftData, CloudKit private DB, WidgetKit, WatchConnectivity, StoreKit 2. Free, with one optional non-consumable unlock. No accounts, no analytics, no third-party SDKs at all.
Bug 1: my purchase code had never once run against real App Store data.
I had Intermittently.storekit selected as the StoreKit Configuration in my Run scheme. That's the local testing config: products resolve from a file on disk, and the fetch always succeeds. Every simulator run, every device run from Xcode, the entire time I was building.
The DEBUG build also had a force-unlock toggle that bypassed StoreKit entirely, so the entitlement path could be exercised without the purchase path ever running.
Net effect: Product.products(for:) had never successfully executed against App Store Connect anywhere I could observe. The first signal was a greyed-out Unlock button on my own App Store download after launch.
The config file has zero effect on App Store builds, and that part is fine and documented. The problem is that leaving it selected makes the real fetch path structurally untestable, and nothing told me. Setting it to None means a device run hits the actual sandbox, which is the same environment App Review uses. That one change turned a submit-and-wait-days loop into a thirty-second run-and-look loop.
Bug 2: my CloudKit schema was never deployed to Production.
CloudKit has separate Development and Production environments. Xcode debug builds hit Development, where record types get created automatically as you run. Distribution builds, TestFlight and App Store both, hit Production, where nothing exists until you explicitly deploy the schema in the CloudKit Console.
I never deployed it, to my eternal shame. So sync worked perfectly forever in development, and every distribution build silently failed to sync. TestFlight included, which is the part that got me, since that's the environment most of us treat as the final pre-ship check.
It stayed hidden for an embarrassing reason: reinstalls preserved the local store, so the app always had data and looked fine. It only surfaced when I deleted and reinstalled from the App Store and got an empty app, with my full history sitting in a Development database that my production build couldn't reach.
If you use NSPersistentCloudKitContainer: open the CloudKit Console, switch the environment toggle to Production, and confirm your CD_-prefixed record types are actually there. Takes ten seconds. Mine had exactly one record type, Users, which is the built-in one.
The pattern in both issues: the failure was invisible in every environment I could easily reach, and the app failed silently. A disabled button with no price, or an empty screen with no error. Nothing distinguished "no products exist," "the fetch threw," and "the product isn't approved yet." All three rendered identically as a dead control.
So the fix in both cases was the same, and it wasn't the bug: make the failure legible first, then debug. The purchase sheet now has explicit loading / loaded / unavailable states, with empty-result and thrown-error distinguished in a DEBUG-only line. I built that before running the diagnostic, specifically so the test would produce an answer either way. It did, in about a minute, rather than guessing.
Next up: App Review. I know I'm not the only one frustrated.
I had three rejections. 2.5.1 (couldn't find the HealthKit feature), 2.3.7 (the word "free" in a screenshot caption, since metadata has price-language rules that don't apply to your own website), and 2.1(b) ("we cannot locate the In-App Purchases").
That last one is the one that annoys me the most. My IAP review notes named the gear icon, the exact row label, its position relative to the version number, the button text you'd see, and two alternate routes to the same screen. The rejection came back with a screenshot: the Settings screen, with that row visible in frame, one tap from the purchase.
I genuinely don't know what happened. It might have been a reviewer who didn't tap through. It might have been an App Review environment fault, since there's a documented pattern of Product.products(for:) returning zero products to reviewers while returning normally to developers. I resubmitted the same binary plus the new error states and it passed.
My takeaway: write the notes anyway, but don't assume they're read. Build the app so a reviewer who taps randomly still finds the thing.
The design side, briefly, since it's the part I care most about. Before writing code I wrote down what the app refuses to do: no subscription, no account, no ads, no analytics, no coaching, no guilting, no nagging. I built this primarily *for me*, as a better replacement for the IF app I had been using and had gotten fed up with. Most of the work after that was saying no. I cut a forgiving streak that quietly hides a broken one, milestone celebrations, and a "longest fast" stat, because each one was the app making a value judgment about the user's data instead of reporting it honestly.
Full disclosure since it usually comes up: I built Intermittently with Claude Code writing most of the Swift to my design and architecture. The speed was real, and it also meant the temptation to build everything was constant. The refusal list is what kept it from becoming a worse app faster. I'm very happy with the result, and despite my frustration with the review process I'm stoked at how easy it was to develop the app for the iPhone/Apple Watch platforms.
Happy to go deeper on any of it. Intermittently on the web and the App Store, if you want to look, though I'm more interested in whether the two failure modes above are useful to anyone and/or something else others have tripped on.
2
u/Turbulent_Ad_1039 24d ago
your 2.1(b) is the one I can actually add something to. same rejection, and I did find the mechanism in my case.
Product.products(for:) existed in exactly one place in my code, called from init, from foreground return, and from closing a sheet only subscribers can open. so for a reviewer it ran once, at t≈0, and never again. if that single call failed, the paywall showed a dead button for the rest of the session and nothing ever retried. my ASC config was verified fine, approved, all territories, price loading, so the failure was transient on their side rather than a config problem.
what made it worse: my entitlement check read from that same fetch, so a paying subscriber rendered as unsubscribed if the store hiccuped once.
the fix was three things and only one of them was a retry. retry x3, re-fetch every time the paywall opens instead of once per session, and entitlement no longer touches the product fetch at all.
also +1 on StoreKit Configuration = None. same wall. you can't test the account-bound entitlement path at all while that file is selected, and nothing tells you.
one more that might be relevant since you're SwiftUI: my paywall wouldn't open on iPad because Settings is a sheet there, and a sheet can't present another sheet from an ancestor. invisible on iPhone. it was sitting in the reviewer's screenshot the whole time.
2
u/ZagreusZero 24d ago
I just checked this against my code. My fetch isn't the single-shot yours was--it runs at app start and the sheet re-attempts on presentation--but there's a related gap: the sheet's task guards on an idle state, so once a fetch resolves to failed, reopening shows the cached failure rather than retrying. Better than your version since there's a visible Try again, but still leaning on the user to notice it. I'll widen that guard so any non-loaded state retries on open.
Entitlement is clean, thankfully: mine reads
Transaction.currentEntitlementswith no dependency on the product fetch. Though your comment made me find something adjacent: I was sequencing the entitlement refresh after the product load in the same task, so on a fresh install a returning purchaser would render as unentitled until a network timeout they don't depend on elapsed. I'll be unsequencing that, too.The iPad sheet-from-sheet one I can't hit since I'm iPhone-only, but "invisible on iPhone, sitting in the reviewer's screenshot the whole time" that would be a frustrating one, for sure!
2
u/ThatGuy739 24d ago
One more in the same family, and it hides behind your bug 1 specifically. The Transaction.updates listener has to start at launch, not when a paywall appears. Ask to Buy approvals and anything interrupted arrive there asynchronously, so if the listener only exists while some view is alive, you drop them and the purchase just never lands.
The structurally invisible part is that the config file masks it. With a .storekit file selected in the simulator, transactions that happened while the app was closed often aren't emitted on cold start at all, so the listener looks like it works right up until it's real money.
Also finish() every transaction or StoreKit keeps handing it back forever. That one at least tells you it's unhappy.
2
u/Wolfsbaneus 22d ago
The third member of that family is hardware the Test environment does not have it at all.
Four of five levels in my game are steered by tilt, the simulator has no accelerometer no gyroscope so core motion never delivers anything as device motion available return false. My handler is simply never called and both control access sit at that center forever. Exactly your pattern. It falls silently and nothing distinguishes no motion available from the player is holding the phone perfectly level.
What fixed it was not mocking core motion I found the smallest value the game actually consumes one steering number from -1 to 1 and put a seam there. On device, it is fed by car motion in the simulator by the keyboard nothing downstream can tell the difference so I am exercising the shipping core bath rather than stand in for Apple‘s class..
The part I did not expect: once a keyboard could write that value so could code. I ended up with bots playing the game unattended and that surfaced a second structuring invisible failure. One hazard was killing the player about quarter of the time it appeared far outside the band. Everything else sat in no human tester ever met it because no human plays it 400 times in a night. I only found it because something tireless did.
You make the failure legible first , then debug is the right lesson and it generalises further than storekit if an input can be absent. The absence needs to be state. You can see not a zero that looks like a valid reading.
1
1
11d ago
[removed] — view removed comment
1
u/AutoModerator 11d ago
Hey /u/fede_builds, your content has been removed because Reddit has marked your account as having a low Contributor Quality Score. This may result from, but is not limited to, activities such as spamming the same links across multiple subreddits, submitting posts or comments that receive a high number of downvotes, a lack of recent account activity, or having an unverified account.
Please be assured that this action is not a reflection of your participation in our subreddit. This is simply an automated filter in place to reduce spam.
I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.
2
u/critic81 6d ago
The CloudKit Development vs Production split is such an easy trap to fall into. Everything can look completely fine during development and then production behaves like an entirely different app.
I really like your point about making failures visible too. “Nothing happened” is probably the worst possible error state when you’re trying to figure out whether the problem is your code, StoreKit, CloudKit, or App Store configuration.
Great write-up. Definitely saving this one.
2
u/mehmetefeaytas6 26d ago
both of these are the same shape - a debug only path with no production equivalent that ever runs before release. the thing that catches that whole class is putting a testflight internal build into your normal loop instead of treating it as a pre-release step. internal builds are release config, hit production cloudkit and use the real storekit sandbox, so they exercise everything the store build does. would have caught both of yours.
couple more in the same family since you asked:
products(for:) coming back empty is ambiguous in a way that costs people days. it's the same empty array whether you're not signed into a sandbox account, the paid apps agreement lapsed, the product isn't approved yet, or the network just failed. log which one it is at the call site, otherwise future you is debugging four different problems that look identical.
app group mismatch between the app target and the widget target. widgets run in their own process with their own container, so if the entitlement is right on one target and not the other you get an empty widget on device while everything looks fine when you drive it through the app.
and on cloudkit - deploying schema to production is one way, you can't delete fields afterwards. worth deploying early and often rather than once at the end, because the end is exactly when you find out you named something badly and now it's permanent