Saturday, June 13, 2026

Modernizing a 9-Year-Old iOS App Without a Rewrite: Realm Device Crashes, OAuth Migration, StoreKit 2 [2026]

App icon for Oyakodon for Mastodon (a parent and child mammoth) See Oyakodon for Mastodon on the App Store

This is a write-up of how I had Claude Code modernize a personal iOS app I had left untouched for years. I didn't write a single line of code — in fact, I never touched the Mac, or even the PC that Claude Code runs on. I just gave instructions from my phone, and Claude Code did everything headlessly on the Mac over SSH: the code changes, the investigation, the builds, the TestFlight delivery, and even the App Store submission. Here I document what was broken and how it got fixed in a roughly 9-year-old Mastodon viewer (about 7.5 of those years completely abandoned), first released back in 2017.

What you'll learn

  • How to have Claude Code modernize an old, abandoned iOS app without writing any code yourself
  • The division of labor (what I asked for vs. what Claude Code asked me to do)
  • The technical updates that resulted (replacing deprecated APIs, OAuth migration, fixing a Realm device-only crash, and more)

Division of labor: what I asked for, and what Claude Code asked of me

In one sentence, what I first asked Claude Code was: "Take Oyakodon, abandoned for 7.5 years, and fix it without a rewrite so it's ready to ship to the App Store again." We hashed out the actual implementation details through back-and-forth, but the one who opened the editor and wrote the code was Claude Code, not me.

What Claude Code did (headlessly, on the Mac):

  • The Swift code changes themselves (all of the Before / After below are Claude Code's edits)
  • Symbolicating crash logs (.ips) with atos to pinpoint the failing locations
  • Hitting the Mastodon API with curl to isolate behavior
  • Running xcodebuild archivealtool on the Mac over SSH and uploading to TestFlight
  • Bumping the build number (CFBundleVersion) and submitting for review on App Store Connect (via the API). The App Store basics (the app record, contracts, baseline info) were already set up from the old-app days, so I never had to log in to App Store Connect anew — Claude Code took it all the way through submission

What I did (only the parts a human can't avoid):

  • Deciding the policy — "modernize without a rewrite" — and sending instructions from my phone
  • Touching the TestFlight builds on my own iPhone and reporting anomalies with screenshots. The "crashes right on launch" and "the bottom edge is misaligned" issues below were both found through this hands-on device check. Notably, even hard-to-describe issues like WebView geometry misalignment only took me sending a single screenshot — it was Claude Code that read the image and isolated the cause

In other words, I never once touched the Mac, nor the PC that Claude Code runs on. I never looked at the code, and I never logged in to App Store Connect. The only thing I physically did was tap through the TestFlight builds that arrived on my iPhone and report what looked wrong. Claude Code's role was "investigation, code changes, builds, delivery, submission"; mine was "deciding direction" and being "the eyes and hands that touch the real device and report what's off."

Background: what had "rotted"

The subject is Oyakodon, a personal iOS app. It's a viewer for juggling multiple Mastodon instances side by side, first released in April 2017 — so the app itself is about 9 years old, and I had completely abandoned it for roughly 7.5 years since its last update in October 2018. The modernized build is now live on the App Store (Oyakodon for Mastodon). When I opened it again after all that time, it wouldn't even build — and the cause wasn't just one thing.

  • iOS SDK side: deprecated APIs had piled up — UILocalNotification, setMinimumBackgroundFetchInterval, UIImageJPEGRepresentation — and private APIs like value(forKey: "statusBar") simply no longer compiled
  • Mastodon side: the web UI's URL routes changed (e.g. /web/timelines/home/home), the API was bumped (v1 → v2), and ID types changed
  • Dependencies: schema changes in Realm (a mobile database), Firebase API revisions, and the review-prompt library Appirater going unmaintained
  • A vanished external service: the API that returned the instance list (instances.mastodon.xyz) was shut down
  • Auth flow: password grant (sending username and password directly) was removed, effectively forcing a migration to the Authorization Code flow

In short, it was corroding from five directions at once: SDK, server, libraries, external services, and auth. Fix one and the next would collapse — the textbook state of an abandoned project.

Approach: modernize in place, without a rewrite

The first thing I decided was "no full rewrite." Oyakodon's differentiator is multi-posting to N instances simultaneously, plus cross-instance boosts (reposts). That core was still working fine in the existing Swift code. Throwing away something that works and rewriting from scratch costs more — the cost of reproducing "it works" is the expensive part.

So I chose in-place modernization: keep the app's skeleton, and peel off only the surface corrosion (deprecated APIs, the dead auth flow, old types) one layer at a time. The work was split into Phases 0–8, with each phase bounded by "the build goes green (succeeds)."

Phase-by-phase summary (all done by Claude Code)

PhaseWorkScale
0iOS 17 build green / CocoaPods → SPM migrationDeleted 917 files under Pods/, auto-converted the pbxproj
1Fix WebView timeline, keep login persisted, externalize settingsSplit the URL route table / CSS / JS into external files
2Remove the dead instance picker, switch to direct domain entryHandling the shutdown of instances.mastodon.xyz
3Change Status / Notification / Attachment IDs from Int → StringImplemented comparison helpers, 16 unit tests
4Move OAuth to the Authorization Code flowAdded a dedicated OAuth WebView screen, registered a URL scheme
5Support /api/v2/media + /api/v2/search, delete dead old-Twitter integration codeImplemented polling for media-processing completion
6Move to BGTaskScheduler + UserNotificationsDropped old background fetch, removed all iOS 10 guards
7Migrate the Share Extension's type systemMobileCoreServices → UniformTypeIdentifiers, deleted 139 lines
8Migrate IAP (in-app purchases) to StoreKit 2Shrank from 250 lines to 79

The final scale was 949 files changed, 2,717 lines added, 225,731 lines deleted. The deletion count is extreme because Phase 0 dropped CocoaPods (a dependency manager) and replaced the large library binaries that had been committed to the repo with SPM (Swift Package Manager, Apple's first-party dependency manager).

What changed technically: Before / After

From here, let's look at some of the more characteristic changes Claude Code made, as Before / After.

1. The code was breaking login on every launch

The pre-abandonment code cleared the cache on every launch — and in doing so swept away the areas that hold login state (localStorage and IndexedDB) too. A well-intentioned "let's keep the cache clean" was, as a result, logging the user out on every launch.

// BEFORE: wipes the cache AND login data on every launch
func removeCache() {
    URLCache.shared.removeAllCachedResponses()
    WKWebsiteDataStore.default().removeData(
        ofTypes: [
            WKWebsiteDataTypeDiskCache,
            WKWebsiteDataTypeOfflineWebApplicationCache,
            WKWebsiteDataTypeSessionStorage,
            WKWebsiteDataTypeLocalStorage,       // <- holds login state
            WKWebsiteDataTypeIndexedDBDatabases  // <- same
        ], modifiedSince: Date(timeIntervalSince1970: 0), completionHandler: {})
    // also deletes everything under Library/Caches...
}
// AFTER: clear only URLCache; don't touch WKWebsiteDataStore
func removeCache() {
    URLCache.shared.removeAllCachedResponses()
}

The fix was simply "don't touch it." The WKWebView (iOS's web rendering component) session is held by WKWebsiteDataStore, so as long as you don't wipe that, the login persists.

2. Mastodon IDs should be String, not Int

The old code held Status (post) IDs as Int, parsing them via NSString. Mastodon IDs keep growing in digit count over time, so treating them as Int risks overflow. Keeping them as plain strings was the right answer.

// BEFORE: parse via NSString (huge IDs risk overflow)
public class Status { public var id: Int! }

if let statusId = (item["id"] as? NSString)?.integerValue {
    status.id = statusId
}
let body = ["since_id": "\(sinceId)"]  // converted to a string every time
// AFTER: pull it straight out of JSON as a String
public class Status { public var id: String! }

if let statusId = item["id"] as? String {
    status.id = statusId
}
if !sinceId.isEmpty { body["since_id"] = sinceId }  // don't send if empty

Stringifying the ID makes ordering a string comparison, so I implemented a comparison helper with the rule "more digits = newer; same digit count = lexicographic," backed by 16 unit tests.

3. OAuth: from password grant to the Authorization Code flow

Because Mastodon removed password grant (exchanging username and password directly for a token), I had to migrate to the Authorization Code flow, which receives an authorization code via the browser.

// BEFORE: password grant (removed)
static func fetchAccessToken(addr: String, clientId: String, clientSecret: String,
                            username: String, password: String, ...) {
    let body = [
        "grant_type": "password",  // <- the removed flow
        "username":   username,
        "password":   password,
        ...
    ]
}
// AFTER: Authorization Code flow (send only the code obtained in the WebView)
static func fetchAccessToken(addr: String, clientId: String, clientSecret: String,
                            code: String, ...) {
    let body = [
        "grant_type":   "authorization_code",
        "redirect_uri": "oyakodon://oauth",
        "code":         code,
        ...
    ]
}

A design choice: instead of Apple's recommended ASWebAuthenticationSession (an OS-provided, auth-dedicated browser), I complete OAuth inside the app's existing WKWebView. The reason is UX. ASWebAuthenticationSession uses a browser session independent of the WKWebView used for the timeline, which would force a double login — "once for the timeline, once more for OAuth." By catching the navigation to oyakodon://oauth?code=... in the existing WebView's navigation delegate (the hook that watches page transitions), you stay in the same session and log in just once.

4. From StoreKit 1 to StoreKit 2 (250 lines → 79)

The IAP (in-app purchase) code was a StoreKit 1 implementation that hand-managed state with the Observer pattern plus delegates. Replacing it with StoreKit 2's async/await base made the state flags and error counters disappear entirely — 250 lines became 79.

// BEFORE: Observer pattern, singleton, delegates (~250 lines)
class PurchaseManager: NSObject, SKPaymentTransactionObserver {
    var delegate: PurchaseManagerDelegate?
    func startWithProduct(_ product: SKProduct) {
        if SKPaymentQueue.canMakePayments() == false { ... }
        // flag management, error counters...
        SKPaymentQueue.default().add(payment)  // result is left to the Observer
    }
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions: ...) {
        // switch over .purchased / .failed / .restored
    }
}
// AFTER: async/await, @MainActor (~79 lines)
@MainActor final class IAPManager {
    static let shared = IAPManager()

    func purchase(productID: String) async throws -> Bool {
        let products = try await Product.products(for: [productID])
        guard let product = products.first else { return false }
        let result = try await product.purchase()
        switch result {
        case .success(let v):                 return await handleVerification(v)
        case .userCancelled, .pending:        return false
        @unknown default:                     return false
        }
    }

    func restore() async throws {
        try await AppStore.sync()
    }
}

I also moved background notifications from the old performFetchWithCompletionHandler to BGTaskScheduler (the iOS 13+ background-task API), and the Share Extension from MobileCoreServices's C constants to UniformTypeIdentifiers' UTType. These are all "replace an old API with a new one one-to-one" jobs, and raising the minimum OS to iOS 17 let me delete every if #available(iOS 10.0, *) branch.

Three bugs that only surfaced on a real device via TestFlight

This is the part I most want to get across. Even with the build passing and everything working perfectly in the simulator, three bugs that crashed or misaligned appeared the moment I put a TestFlight build on a real device (iPhone). The flow was: I touched the device and reported the anomaly, and Claude Code symbolicated the .ips crash log with atos to pinpoint the cause. Every one of these would have first surfaced on a user's device after release if I'd only watched the simulator.

(1) Realm crashes immediately on device launch (missing embed)

RealmSwift added via SPM is type: .dynamic — that is, always a dynamic framework (loaded at runtime). But when Phase 0 auto-converted the pbxproj (Xcode's project settings file), it only set up the framework "link" and never added the "embed" (the Embed Frameworks phase).

As a result, it works in the simulator (which embeds it via another path) but crashes on a real device right after launch with dyld: Library not loaded: @rpath/RealmSwift.framework.

A quick way to tell: a healthy IPA (the iOS app package) bundles RealmSwift.framework and comes out to about 12MB. If it's only 2.5MB, the embed is missing. Just checking the IPA size before uploading tells you instantly.

(2) The trap of ignoring Realm's Configuration

The second one is also Realm. I had set schemaVersion and migrationBlock (the schema-migration handler) on Realm.Configuration.defaultConfiguration, yet it still crashed on launch.

The cause was opening the DB with Realm(fileURL:). That initializer ignores defaultConfiguration. So the existing DB's schema version (2) and the new default (0), which never picked up my settings, were mismatched.

// NG: schemaVersion / migrationBlock on defaultConfiguration are ignored
let realm = try Realm(fileURL: dbURL)

// OK: pass the Configuration explicitly
var config = Realm.Configuration.defaultConfiguration
config.fileURL = dbURL
let realm = try Realm(configuration: config)

(3) WebView bottom-edge misalignment (took three fixes to truly resolve)

The third is a misalignment where, at the bottom of the screen, the WebView slightly overlaps the toolbar or leaves a gap. I fixed this three times in total — in 2.7.2 build 2, 2.7.3 build 1, and 2.7.3 build 2 (shipping to TestFlight and checking on the device each time). The first two were symptomatic treatments; the root cause was "three parties fighting over the geometry (position and size)."

It's hard to convey in words exactly "how many points it overlaps," but all I did was screenshot the device screen and send it. Claude Code read the amount and direction of the misalignment from the image and narrowed down the cause. This was a moment where a problem that would have taken many round-trips with text alone moved forward with a single image. Here are the actual before / after device screenshots I sent.

Before: device screenshot where the WebView overlaps the toolbar at the bottom, hiding content Before: the bottom edge overlaps the toolbar After: device screenshot where pinning the WebView's four edges with Auto Layout resolves the bottom misalignment After: pinning all four edges resolves it
The before / after I shot on the device and sent to Claude Code. It read the amount of misalignment from these images.
  • Storyboard Auto Layout: views with translatesAutoresizingMaskIntoConstraints = false snap back to their constraint values on every layout pass
  • Manual frame math: the menu-bar show/hide logic used the deprecated statusBarFrame and ignored the 34pt bottom safe area (the home-indicator region)
  • Resize handling: it snapshotted the container height at that instant, so it couldn't follow changes after the toolbar's open/close animation finished

These three kept overwriting the frame values every time, so wherever you fixed it, another piece of logic would roll it back. The real fix was "drop the manual frame math and resize handling, and pin the WebView's four edges to the container with Auto Layout." Pin it once, and the layout follows automatically from then on.

// AFTER: pin all four edges with Auto Layout -> follows automatically afterward
let webView = self.webViewController.webView
webView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    webView.topAnchor.constraint(equalTo: webContainerView.topAnchor),
    webView.bottomAnchor.constraint(equalTo: webContainerView.bottomAnchor),
    webView.leadingAnchor.constraint(equalTo: webContainerView.leadingAnchor),
    webView.trailingAnchor.constraint(equalTo: webContainerView.trailingAnchor),
])
// removed the manual resizeWebView()

Notes on design decisions

  • Don't rewrite: the differentiators (multi-posting, cross-instance boosts) were working in the existing code, so keeping them and only fixing the surface was the surer bet.
  • CocoaPods → SPM: the immediate trigger was a build-environment constraint, but the bigger side effect was wiping out 50MB+ of binaries that had been committed to the repo.
  • Externalizing config: Mastodon's web UI routes will keep changing. Splitting the URL path table / CSS / JS into JSON and files makes it easier to follow the next change without rebuilding the app itself. It's also aimed at sharing the spec when I eventually write an Android version.
  • App Store review risk: an app that merely displays a site in a WebView can be rejected under guideline 4.2 (minimum functionality). The native layers — multi-posting, OAuth, notifications, purchases — are what form that line of defense.

In the end, 2.7.2 is released, and I got 2.7.3 to a state I could submit for App Store review. The actual work took about a week.

FAQ

Q. Did you write the code yourself?

A. No — not a single line, and I never even looked at the code. I just decided the policy ("modernize without a rewrite") and instructed from my phone; Claude Code did the code changes, investigation, builds, TestFlight delivery, and App Store submission headlessly on the Mac over SSH. I never touched the Mac or the PC Claude Code runs on. The only thing I physically did was tap through the TestFlight builds that arrived on my iPhone and report anomalies. The App Store Connect submission was also done by Claude Code via the API (the App Store basics were already set up from the old-app days, so no new login was needed).

Q. Why does it crash on a real device when it works in the simulator?

A. Because the parts the simulator stands in for — whether dynamic frameworks are embedded, code signing, the device-specific safe area — are exposed on a real device. The Realm crash here was exactly that, so before release you should always deliver to a real device via TestFlight and confirm it launches.

Q. Wouldn't rewriting the old app be faster?

A. If the differentiators still work, I don't recommend a rewrite, because reproducing "it works" is costly. Conversely, if the core itself is outdated or broken, a rewrite can be faster. The deciding factor is "is what's broken the inside, or the surface?"

Q. Is completing OAuth inside a WebView safe?

A. Security-wise, ASWebAuthenticationSession is preferable. Here I prioritized the UX of avoiding a double login and kept it inside WKWebView, but for apps where you don't control the authorization server, consider Apple's recommended approach.

Note: the code in this article is simplified from what actually ran on the Xcode / iOS SDK / libraries at the time of writing (June 2026). If versions change, it may not work as-is. If something doesn't work, let me know in the comments. Environment-specific values like the Bundle ID have been replaced with placeholders.

Wrap-up

Even a roughly 9-year-old iOS app (about 7.5 of those years abandoned) can be fixed without a rewrite if what's broken is the "surface." This time I wrote no code and looked at none, never touched the Mac, and stuck to setting direction and checking on my own iPhone, leaving the code changes, builds, TestFlight delivery, and App Store submission to Claude Code. The hard parts were the Realm issues and the WebView geometry — things that build fine but only break on a real device. Don't trust the simulator too much; delivering to a real device via TestFlight to verify is the shortcut.

If this article helped, I'd be glad if you shared it on X (Twitter).

Related posts

References

Note: this article is an experiment in a Claude Code–assisted writing flow.

No comments:

Post a Comment