Portfolio · The iOS Journey

Shipping Two AI Apps Through Apple's Review

PJ By PJ Geldenhuis · Case study · updated July 15, 2026 · 2 apps · real review cycles

Building an AI feature is one skill. Getting the app that wraps it approved and onto real phones — through native auth, StoreKit, App Store Connect, and a human reviewer who will reject you — is a different one, and it's exactly the "took it to production" signal a hiring manager is looking for. Two of my products are iOS apps that went through real Apple review by two deliberately different routes. This page is the shipping story: the versions, the iterations, and the rejections.

Capacitor hybrid + native SwiftUI Xcode Cloud from Windows 4 rejections, all resolved or in-flight

Two apps, two architectures, one hard-won playbook

The two apps take opposite approaches to the same problem — put an AI product on iOS — and the contrast is the point. What ties them together is institutional memory: the mistakes one app made became the other's checklist, twice over.

Lumenor — hybrid

  • A Capacitor WebView shell that loads getlumenor.com: deploy the web app, and the app updates. Most fixes ship to Vercel with no new build.
  • Native Sign in with Apple/Google bridged into Clerk.
  • CI: Xcode Cloud → TestFlight. Bundle com.lumenor.app.

Tiglit — native

  • A native SwiftUI app (its own repo), SwiftUI + StoreKit 2 + native GoogleSignIn, min iOS 16, generated with XcodeGen from a project.yml.
  • Built with no Mac of my own: one MacInCloud session to generate the project, then Xcode Cloud CI.
  • Bundle com.realtamagotchi.app, display name already flipped to "Tiglit."

Lumenor iOS — the hybrid, and a two-layer-deep auth bug

Lumenor went from a bare compiler to a mostly-complete app skeleton — including a native iOS shell — in a single day. The genuinely hard part wasn't the shell; it was getting native Sign in with Apple/Google to work inside it, and the reason it was hard is the kind of thing you only learn by shipping.

The native-auth saga, in the order it actually unfolded

  • Hypothesis 1 — wrong"Missing native-app registration in the auth dashboard." Fixed it; identical errors persisted. Not the cause.
  • Root cause, layer 1The web SDK running inside the WebView has no native OAuth-token strategy at all — those live only in dedicated native mobile SDKs. No dashboard setting could ever fix it; the fix had to be code.
  • Root cause, layer 2Even after registering the native app, clerk-js can't use oauth_token_* at all. Rewrote it: Google via authenticateWithGoogleOneTap, and a new backend route that verifies the Apple ID token (jose), mints a sign-in token, and redeems it with signIn.create({strategy:'ticket'}).
  • Signing gotchaThe Apple entitlement was enabled mid-session, after the test build was already signed — so it took two more builds before it actually worked. Google worked immediately (pure web code, no rebuild).
  • ResolvedBoth fixes are web-side — a Vercel deploy was enough, no new TestFlight build. Email sign-in kept as the always-works fallback throughout.

The lesson that generalizes: when the same error survives the "obvious" dashboard fix, the cause is a layer deeper than the dashboard. Reading the auth provider's own SDK source — not guessing — is what found it.

Then App Store Connect wouldn't let me submit

Six consecutive build uploads showed up in the build-selection screen with their radio buttons permanently greyed out. The root cause was not a bug: builds produced with the CI workflow's distribution set to "Internal Testing Only" are permanently ineligible for App Store submission, by design. The fix — one dropdown and a fresh build — came from pasting in troubleshooting notes from my other app, which had hit the identical wall before. Institutional memory paying off across two apps in the same portfolio.

The deliberate paywall decision

Facing the first submission, I shipped Lumenor free rather than wiring a subscription paywall first — a specific, risk-based call: the only App Store rejection I'd ever eaten was a subscription-disclosure guideline on the other app, so I concentrated first-submission review risk away from the least-tested surface. Zero users meant zero revenue foregone, and beta usage would price the paid tier honestly later. (As the next section shows, that traded one risk for another — honestly reported.)

Build 30 → Build 31: the three-count rejection, each traced to a guideline

  • Rejected · 2.3.8Placeholder-looking icon. The temporary icon read as an unfinished export. Fix: the finalized 1024px "North Star" brand mark — the one asset that genuinely needed a new Xcode Cloud build.
  • Rejected · 2.2"Appears to be a pre-release/beta." Triggered by the app's own "beta / everything unlocked while in beta" copy — the very language chosen a week earlier to reduce a different risk. Fix: stripped all beta wording everywhere, and actually unlocked the gated features (V1_ALL_FEATURES_FREE) so it's a complete free product, not a limited one.
  • Rejected · 5.1.1(i) / 5.1.2(i)Third-party AI consent. Sharing user data with AI services needs an explicit in-app consent step, not just a privacy page. Fix: a one-time /welcome/ai-consent screen naming Anthropic / Deepgram / ElevenLabs and what each receives, recorded in consent_record + user.aiConsentAt (additive migration 0009), withdrawable via account deletion.
  • Build 31All three fixed. Because the shell loads the live site, only the icon required a rebuild — the rest shipped to Vercel prod. Resolution-Center reply + demo account prepared; resubmitted.

Worth naming: two of the three findings were presentation problems, not capability ones — and one of them was caused by an earlier attempt to de-risk a different guideline. Shipping through review is a game of trading one reviewer-facing risk for another and being honest about the trade.


Tiglit iOS — native, three rounds, and a rebrand forced by Apple

Tiglit took the harder-but-fuller-control path: a native SwiftUI app (StoreKit 2 in-app purchases, native GoogleSignIn, Speech and PhotosUI frameworks), built and shipped from a Windows machine with no Mac of my own — one ~$1 MacInCloud session to generate the Xcode project, then Xcode Cloud handled every build after a push to main.

The native iOS stack

Language / UISwiftUImin iOS 16
Project genXcodeGen (project.yml)MacInCloud (one session)
CommerceStoreKit 2$9.99/mo · $79/yr · $99 lifetime
Auth / mediaGoogleSignInSpeech · PhotosUI · AVFoundation
CI / deliveryXcode CloudTestFlight

The same App Store Connect build-selection wall showed up here first, in fact — greyed-out, unselectable builds that resisted diagnosis long enough to escalate to an actual phone call with Apple's developer support. The root cause was a misconfigured CI workflow setting hidden behind a save button that had silently not saved. (This is the incident whose notes later rescued Lumenor's identical symptom.)

Three rounds with Apple — every iteration

  • Round 1 · Jun 15 · 3.1.2(c)Subscription disclosure. No Terms-of-Use link in the metadata, and the paywall didn't disclose auto-renewal (trial length, price, renew-unless-cancelled). Fix: the premium page now carries the legal block + tappable EULA/Privacy links, and the rule that the paywall must show the live StoreKit price, never a hardcoded one.
  • Round 2 · Jun 21 · the big oneThe app's own name was flagged as a trademark concern against a well-known toy property the original name referenced. This is the direct, documented trigger for the entire rebrand — new name, new mascot, new domain.
  • Round 3 · Jul 6Screenshots showing literal pricing. Fixed. The next review dropped the name issue entirely, confirming the rebrand cleared it.
  • Honest open itemThe round-3 fix was drafted, but a final "Ready for Sale" approval isn't confirmed in any available record — so I say "cleared two of three known rounds with a fix in for the third," not "shipped."

The rebrand: the largest single iteration

Triggered entirely by that round-two trademark rejection, the rebrand touched everything: a new name (Tiglit), a new tiger-cub mascot replacing a leftover hamster-and-egg concept, a new domain, and a color system iterated live by feel (an initial muddy dark-brown chrome, replaced with a teal chosen specifically as a complementary contrast to the orange cub). It also broke auth twice, because production auth keys are domain-locked — switching the primary domain silently issued a brand-new key that had to be copied and rebuilt, plus a separate OAuth redirect to update. Both were diagnosed by reading the live browser console, not guessing.

What the review cycles cost

  • Four total rejections across the two apps (one Lumenor, three Tiglit), plus a submission-blocking ASC bug on each.
  • An entire rebrand — name, mascot, domain, colors, auth — forced by a single guideline finding.
  • Real cross-app auth breakage on every domain migration (keys are domain-locked).

What they built

  • A reusable App-Review playbook: demo account, screen recording of the fix, a Resolution-Center reply template, live-price paywall disclosure.
  • Guideline literacy — 2.2, 2.3.8, 3.1.2(c), 5.1.1(i)/5.1.2(i) — mapped to concrete code changes.
  • A no-Mac CI path (Windows → GitHub → Xcode Cloud → TestFlight) that works.
What I built vs. what AI assisted I used AI-assisted development extensively, while personally owning product definition, architecture, integration design, implementation decisions, testing, debugging, deployment, and production operation — including every App Store submission, rejection response, and the decision to rebrand an entire product rather than fight a trademark finding.
The throughline: the iOS work is where "hands-on to production" stops being a claim and becomes a paper trail — native auth debugged to its real root cause, four rejections each traced to a specific guideline and a specific fix, a CI pipeline that ships from a machine without a Mac, and a full rebrand executed under a real external constraint. It's the least glamorous and most convincing part of the portfolio.
Flagship: DoorOps — Production & agent safety → Flagship: Lumenor — Evaluation & guardrails → ← Portfolio home Tiglit devlog →