June 1, 2026·5 min read

Building an iOS App With Xcode on Claude Code

XcodeGen, Swift 6 concurrency traps, iOS 26 API changes, and more!

I've been building J.A.R.V.I.S. — a personal AI assistant and home automation system that runs on my own hardware — and one of the pieces I wanted was a native iOS app.

About a year ago I tried building a Tamagotchi clone for fun. That was before Claude Code existed, and I was trying to work in VS Code. It was immediately obvious why that doesn't work: so much of iOS development is locked inside Xcode. Creating files and having them registered in the project, managing capabilities and entitlements, code signing, the asset catalog, provisioning profiles — all of it lives in Xcode-specific formats and tooling that VS Code has no understanding of. You'd make a change outside Xcode, and Xcode wouldn't know about it. The feedback loop was broken. I eventually shelved it.

Claude Code changes this almost entirely. The insight is that you don't need Xcode to be your editor — you just need Xcode to be your compiler and device runner. Claude Code reads and edits .swift files directly, manages project structure through XcodeGen, runs xcodebuild and other shell tools, and reads Xcode console output when I paste errors in. The only things I still do manually are hit ⌘R to build and run, handle the one-time Apple Developer account setup, and paste console output when there's a crash.

XcodeGen: the key architectural decision

The project uses XcodeGen to manage the Xcode project file. XcodeGen generates MyApp.xcodeproj from project.yml — a human-readable YAML spec. The .xcodeproj is gitignored and always re-generated, never committed.

This matters for Claude Code because it can edit project.yml directly to add capabilities, change deployment targets, add Swift packages, and configure signing — without ever opening Xcode. After any change:

xcodegen generate

Run whenever: new Swift files are added, project.yml is modified, or the project gets out of sync.

The team ID trap

Always hardcode DEVELOPMENT_TEAM in project.yml.

If it's set to $(DEVELOPMENT_TEAM) (a variable reference), every xcodegen generate clears the team ID and breaks code signing. Set it as a literal string:

settings:
  base:
    DEVELOPMENT_TEAM: YOURTEAMID
    SWIFT_VERSION: "6.0"

New files need a regenerate

All .swift files under the source path are included via glob — you don't list them individually. But after adding new files, run xcodegen generate before building, or Xcode fails with "Cannot find type X in scope". Claude Code does this automatically after creating new source files, but it's easy to miss if you add one manually.

Swift 6 strict concurrency

Targeting Swift 6 adds strict actor isolation checking. A few patterns that worked in Swift 5 break silently here.

@MainActor on UIKit callers

UIKit feedback generators require main-actor isolation:

// ✅ Correct
@MainActor
enum Haptics {
    static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .light) {
        UIImpactFeedbackGenerator(style: style).impactOccurred()
    }
}

// ❌ Swift 6 error: "call to main actor-isolated method in nonisolated context"
enum Haptics {
    static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .light) {
        UIImpactFeedbackGenerator(style: style).impactOccurred()
    }
}

Timer.publish instead of Timer.scheduledTimer

@State is @MainActor-isolated. A Timer.scheduledTimer closure is @Sendable and can't mutate @State directly:

// ❌ "Main actor-isolated property cannot be mutated from Sendable closure"
.onAppear {
    Timer.scheduledTimer(withTimeInterval: 0.45, repeats: true) { _ in
        phase = (phase + 1) % 3
    }
}

// ✅ Timer.publish fires on main run loop
private let ticker = Timer.publish(every: 0.45, on: .main, in: .common).autoconnect()

var body: some View {
    HStack { ... }
        .onReceive(ticker) { _ in
            phase = (phase + 1) % 3
        }
}

iOS 26 API changes

Font API ambiguous overloads

.font(.system(.title3, weight: .semibold, design: .default)) can resolve to the wrong overload in iOS 26. Use the explicit modifier chain:

// ❌ May resolve to wrong overload
.font(.system(.title3, weight: .semibold, design: .default))

// ✅ Unambiguous
.font(.title3)
.fontWeight(.semibold)
.fontDesign(.default)

String interpolation specifier label removed

// ❌ Swift 6 error: "Incorrect argument label"
Text("\(value, specifier: "%.1f")")

// ✅
Text(String(format: "%.1f", value))

frame(minHeight:) mixed with fixed width

// ❌ Not a valid overload
.frame(width: 20, minHeight: 44, alignment: .top)

// ✅
.frame(width: 20, height: 44, alignment: .top)

Date decoding

Swift's .iso8601 decoder fails on two common API date formats:

  • "2026-05-31T17:08:52.029Z" — ISO8601 with fractional seconds
  • "2026-05-31 17:01:49.860581+00" — PostgreSQL timestamptz

This fails silently — some endpoints decode cleanly, others crash with "data could not be read because it isn't in the correct format." A multi-format custom decoder handles both:

d.dateDecodingStrategy = .custom { decoder in
    let s = try decoder.singleValueContainer().decode(String.self)

    let isoFull = ISO8601DateFormatter()
    isoFull.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
    if let date = isoFull.date(from: s) { return date }

    let isoPlain = ISO8601DateFormatter()
    if let date = isoPlain.date(from: s) { return date }

    let pg = DateFormatter()
    pg.locale = Locale(identifier: "en_US_POSIX")
    pg.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSSZZZZZ"
    if let date = pg.date(from: s) { return date }

    pg.dateFormat = "yyyy-MM-dd HH:mm:ssZZZZZ"
    if let date = pg.date(from: s) { return date }

    throw DecodingError.dataCorruptedError(
        in: try decoder.singleValueContainer(),
        debugDescription: "Cannot parse date: \(s)"
    )
}

Also: any API field that might occasionally be absent must be Optional in the Swift struct. A missing required field throws "missing key" — not a graceful skip.

App icon: the invisible PNG

After updating the app icon, the device showed a blank white square. No Xcode error. No crash. Deleted, cleaned (⇧⌘K), reinstalled. Still blank. Multiple times.

Root cause: iOS app icons must be RGB — no alpha channel. Xcode silently ignores RGBA icons.

# Check
sips -g hasAlpha AppIcon-1024.png  # "hasAlpha: yes" = problem

# Fix
magick AppIcon-1024.png \
  -background "#000000" \
  -alpha remove -alpha off \
  -type TrueColor \
  -depth 8 \
  AppIcon-1024.png

Any PNG exported from Figma or converted from SVG with a transparent background will have an alpha channel. iOS rejects it silently.

← All posts