Help us improve
Share bugs, ideas, or general feedback.
From ibr
Use when building iOS, watchOS, or macOS apps — architecture, SwiftData, Swift concurrency, CI/CD, TestFlight. How to build. For HIG design rules see ios-design.
npx claudepluginhub tyroneross/interface-built-right --plugin ibrHow this skill is triggered — by the user, by Claude, or both
Slash command
/ibr:apple-platform [ios|watchos|macos|multiplatform] [feature description][ios|watchos|macos|multiplatform] [feature description]The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Build production-grade iOS, watchOS, and macOS apps with SwiftUI. Covers architecture, platform abstraction, Watch connectivity, state management, concurrency, testing, and deployment.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Provides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Provides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Share bugs, ideas, or general feedback.
Build production-grade iOS, watchOS, and macOS apps with SwiftUI. Covers architecture, platform abstraction, Watch connectivity, state management, concurrency, testing, and deployment.
Before writing any code for a major change (see triggers below), dispatch two parallel subagents:
Trigger conditions -- any ONE of:
Shared/ code (cross-platform impact)Skip for: typos, single-view additions within one platform folder, comments, running builds/tests.
Launch both simultaneously using the Agent tool:
Subagent 1 — Architecture Research (subagent_type: Explore)
Explore the iOS/watchOS project at [PROJECT_PATH].
Assess impact of [PROPOSED CHANGE]:
1. Read project config (project.yml or *.xcodeproj)
2. Read Shared/Protocols/ and Shared/Models/
3. Read the specific files to be modified
4. Map which platforms and delegates are affected
5. Check for existing tests covering affected code
Produce: affected files, platform impact, data model changes,
risks, and recommended implementation order.
Subagent 2 — Version & Compatibility Check (subagent_type: general-purpose)
Run: bash ${CLAUDE_PLUGIN_ROOT}/scripts/apple-platform/apple-version-check.sh [PROJECT_PATH]
Then verify: are the APIs needed for [PROPOSED CHANGE] available
at the project's current deployment targets?
Flag any deprecations, minimum version requirements, or Swift
feature availability issues.
Synthesize findings into a brief implementation plan before writing code:
See ${CLAUDE_PLUGIN_ROOT}/references/apple-platform/pre-implementation-research.md for full protocol details.
Date math (start.timeIntervalSince(now) - pausedDuration), never integer decrement. Eliminates jitter across all platforms.For multiplatform apps (iOS + watchOS), use this structure:
MyApp/
project.yml # XcodeGen (or .xcodeproj)
Shared/ # Both targets -- models, services, utilities
Models/
Services/
Extensions/
Protocols/
iOS/ # iOS-only
App/ # Entry point, AppDelegate
Features/ # Feature modules
Views/ # iOS-specific UI
Widgets/ # Live Activities, WidgetKit
Resources/
watchOS/ # watchOS-only
App/
Features/
Views/
Complications/
Resources/
macOS/ # macOS-only (if applicable)
App/
Features/
Views/
Tests/
UnitTests/ # Shared logic tests (macOS host)
UITests/ # iOS UI tests
Packages/ # Local Swift Packages (optional)
SharedKit/
com.example.app (iOS), com.example.app.watchkitapp (watchOS)The highest-value pattern for multiplatform apps. Core engine owns all business logic; delegates handle platform concerns.
// Shared/Protocols/EngineDelegate.swift
protocol AppEngineDelegate: AnyObject {
func didUpdateState(_ state: AppState)
func scheduleNotification(title: String, body: String, delay: TimeInterval)
func playHaptic(pattern: HapticPattern)
func persistSession(_ session: Session)
func handlePlatformTransition()
}
// Shared/Services/AppEngine.swift
@MainActor @Observable
class AppEngine {
weak var delegate: AppEngineDelegate?
var state: AppState = .idle
func start() {
state = .running
delegate?.didUpdateState(state)
}
}
// iOS/Services/IOSEngineDelegate.swift
@MainActor
class IOSEngineDelegate: AppEngineDelegate {
func playHaptic(pattern: HapticPattern) {
// CoreHaptics implementation
}
func handlePlatformTransition() {
// Live Activity updates, HealthKit capture
}
}
// watchOS/Services/WatchEngineDelegate.swift
@MainActor
class WatchEngineDelegate: AppEngineDelegate {
func playHaptic(pattern: HapticPattern) {
WKInterfaceDevice.current().play(.click)
}
func handlePlatformTransition() {
// Extended runtime session, complication update
}
}
Why this works: Engine is testable without simulators. New platforms (visionOS, tvOS) require only a new delegate. Platform bugs are isolated.
Prefer @Observable over ObservableObject + @Published for new code:
@Observable class ViewModel {
var items: [Item] = [] // Automatically tracked
var isLoading = false // No @Published needed
}
// In views:
@State private var vm = ViewModel() // View owns it
@Environment(ViewModel.self) var vm // Injected
@Bindable var vm: ViewModel // For bindings
For persistence targeting iOS 17+:
@Model class Session {
var startDate: Date = Date()
var duration: Int = 0
var mode: String = "focus"
// All properties optional or defaulted for CloudKit compat
// NO #Unique constraints with CloudKit
@Relationship(deleteRule: .cascade) var entries: [Entry] = []
}
// View is the view model -- no separate VM wrapping @Query
struct SessionListView: View {
@Query(sort: \Session.startDate, order: .reverse) var sessions: [Session]
@Environment(\.modelContext) private var context
var body: some View {
List(sessions) { session in ... }
}
}
// Background work requires @ModelActor
@ModelActor actor BackgroundProcessor {
func importSessions(_ data: [SessionData]) throws {
for item in data {
let session = Session(...)
modelContext.insert(session)
}
try modelContext.save()
}
}
@Query -- the view IS the view model@State with reference types -- causes recreation every render@Query with predicates#Unique constraints with CloudKit -- causes silent failures| Method | Use Case | Delivery | Limit |
|---|---|---|---|
sendMessage() | Real-time commands | Instant (both active) | None |
updateApplicationContext() | Latest-state sync | Background, last-wins | 1 pending |
transferUserInfo() | Guaranteed records | Background, FIFO queue | None |
transferCurrentComplicationUserInfo() | Complication data | High priority | 50/day |
// Shared/Services/SyncManager.swift
@MainActor @Observable
class SyncManager: NSObject, WCSessionDelegate {
var isReachable = false
private var session: WCSession?
func activate() {
guard WCSession.isSupported() else { return }
session = WCSession.default
session?.delegate = self
session?.activate()
}
func sendCommand(_ command: [String: Any]) {
guard let session, session.isReachable else {
try? session?.updateApplicationContext(command)
return
}
session.sendMessage(command, replyHandler: nil)
}
nonisolated func session(_ session: WCSession,
didReceiveMessage message: [String: Any]) {
Task { @MainActor in
handleReceived(message)
}
}
}
sendMessage can wake iPhone app but is unreliable for waking Watch appHKHealthStore.startWatchApp(with:) to launch Watch from iPhone (health apps only)When Watch displays iPhone state (not running its own logic):
var remoteState: RemoteTimerState?
var isLocalMode = false
var displayTime: TimeInterval {
if isLocalMode { return localEngine.elapsed }
guard let remote = remoteState else { return 0 }
return Date().timeIntervalSince(remote.startDate) - remote.pausedDuration
}
Background timer budget is ~1 hour:
class BackgroundManager {
var extendedSession: WKExtendedRuntimeSession?
func startExtendedSession() {
extendedSession = WKExtendedRuntimeSession()
extendedSession?.delegate = self
extendedSession?.start()
}
func extendedRuntimeSessionWillExpire(_ session: WKExtendedRuntimeSession) {
saveState()
scheduleLocalNotification()
}
}
isLuminanceReduced -- reduce update frequency when wrist is downTimelineSchedule for adaptive refresh (1Hz active, 10-20s idle)TabView on watchOS (memory leaks)Only 5-10% of watchOS crashes appear in Xcode. Implement local logging sent via transferUserInfo() to iPhone.
// UI-bound classes: @MainActor
@MainActor @Observable class ViewModel {
var items: [Item] = []
func load() async throws {
items = try await api.fetchItems()
}
}
// Shared mutable state: actor
actor DataStore {
private var cache: [String: Data] = [:]
func get(_ key: String) -> Data? { cache[key] }
func set(_ key: String, data: Data) { cache[key] = data }
}
// Parallel work: async let
async let profile = fetchProfile()
async let posts = fetchPosts()
let (p, ps) = try await (profile, posts)
// Background processing
nonisolated func processData(_ data: Data) -> Result {
// Runs off main actor
}
@MainActornonisolated or Task.detachedasync/awaitasync let (simple) or TaskGroup (dynamic)actorTask.isCancelled in long workTaskGroup) preferred over unstructured (Task {}).task modifier in views -- auto-cancels on disappearstruct MyActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var progress: Double
var label: String
}
var name: String
}
// Start
let attributes = MyActivityAttributes(name: "Timer")
let state = MyActivityAttributes.ContentState(progress: 0.5, label: "Working")
let activity = try Activity.request(
attributes: attributes,
content: .init(state: state, staleDate: nil)
)
// Update
await activity.update(.init(state: newState, staleDate: nil))
// End
await activity.end(.init(state: finalState, staleDate: nil), dismissalPolicy: .default)
HKHealthStore for heart rate, sleep, exercise, meditationUse NavigationStack with type-safe routing:
enum Route: Hashable {
case detail(Item)
case settings
case profile(userId: String)
}
@Observable class Router {
var path = NavigationPath()
func navigate(to route: Route) { path.append(route) }
func popToRoot() { path = NavigationPath() }
}
struct ContentView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .detail(let item): DetailView(item: item)
case .settings: SettingsView()
case .profile(let id): ProfileView(userId: id)
}
}
}
.environment(router)
}
}
90% unit tests (milliseconds) / 10% UI tests (critical flows only):
// Test @Observable model logic
@Test func calculatesCorrectly() {
let model = BudgetModel(limit: 500)
model.addExpense(100)
model.addExpense(200)
#expect(model.spent == 300)
#expect(model.remaining == 200)
}
// SwiftData with in-memory container
@Test func persistsSession() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Session.self, configurations: config)
let context = container.mainContext
context.insert(Session(duration: 300))
try context.save()
let fetched = try context.fetch(FetchDescriptor<Session>())
#expect(fetched.count == 1)
}
// Async testing
@Test func loadsItems() async throws {
let vm = ViewModel(service: MockService())
await vm.load()
#expect(vm.items.count == 3)
}
makeSession(mode: .focus, duration: 300)| Issue | Fix |
|---|---|
@State with reference types | Use only with value types |
| Expensive computed properties in body | Cache in init or memoize |
Index-based ForEach | Use Identifiable with stable IDs |
| ViewModels created in NavigationLink | Create once, inject via environment |
| Missing environment in sheets | Explicitly pass .environmentObject() |
GeometryReader overuse | Constrain with .frame(), use sparingly |
| watchOS nested TabView | Flat navigation only |
Ignoring isLuminanceReduced | Reduce updates when wrist down |
Text).accessibilityLabel() on all non-text interactive elements.accessibilityElement(children: .combine) for grouped contentSet up via Product > Xcode Cloud > Create Workflow. Handles code signing, certificates, notarization automatically. Custom scripts in ci_scripts/.
lane :beta do
match(type: "appstore")
build_app(scheme: "MyApp", export_method: "app-store")
upload_to_testflight
end
For apps with long-running state (timers, workouts):
func saveActiveSession() {
UserDefaults.standard.set(session.id.uuidString, forKey: "activeSession_id")
UserDefaults.standard.set(Date(), forKey: "activeSession_date")
UserDefaults.standard.set(elapsed, forKey: "activeSession_elapsed")
}
func checkForRecovery() -> RecoverableSession? {
guard let dateStr = UserDefaults.standard.object(forKey: "activeSession_date") as? Date,
Date().timeIntervalSince(dateStr) < 5400 // 90min staleness
else {
clearRecoveryData()
return nil
}
return RecoverableSession(...)
}
For real-time sync between devices on same WiFi (Mac <-> iPhone):
// Advertise
let listener = try NWListener(using: .tcp)
listener.service = NWListener.Service(name: "MyApp", type: "_myapp._tcp")
listener.newConnectionHandler = { connection in ... }
listener.start(queue: .main)
// Discover
let browser = NWBrowser(for: .bonjour(type: "_myapp._tcp", domain: nil), using: .tcp)
browser.browseResultsChangedHandler = { results, changes in ... }
browser.start(queue: .main)
Relay architecture: Mac <-> iPhone (Network Framework) <-> Watch (WCSession). Watch never connects directly to Mac.
@Model properties must be optional or have defaults (CloudKit requirement)#Unique constraints (unsupported with CloudKit)#if os(iOS) / #if os(watchOS) / #if os(macOS) for platform-specific code, not runtime checksFor apps in active development, configure auto-push + Xcode Cloud deploy on significant changes.
Major change triggers:
[deploy], [release], or [major]Shared/ code changed (affects all platforms)project.yml, Info.plist, .entitlements)Setup:
${CLAUDE_PLUGIN_ROOT}/references/apple-platform/build-automation.md)For Claude Code integration, add a PostToolUse hook to auto-push after git commit when major change detected. See ${CLAUDE_PLUGIN_ROOT}/references/apple-platform/build-automation.md for full configuration.
Quick validation before deploy:
${CLAUDE_PLUGIN_ROOT}/scripts/apple-platform/xcode-validate.sh .
For detailed patterns and reference material, consult:
${CLAUDE_PLUGIN_ROOT}/references/apple-platform/watch-connectivity.md -- WCSession deep dive, channel selection, mirror mode, gotchas${CLAUDE_PLUGIN_ROOT}/references/apple-platform/performance.md -- SwiftUI performance pitfalls, watchOS battery optimization, Instruments usage${CLAUDE_PLUGIN_ROOT}/references/apple-platform/common-mistakes.md -- 22 categorized mistakes with fixes (architecture, SwiftUI, watchOS, concurrency, data)${CLAUDE_PLUGIN_ROOT}/references/apple-platform/swiftdata-patterns.md -- SwiftData vs CoreData decision matrix, CloudKit compatibility, background operations${CLAUDE_PLUGIN_ROOT}/references/apple-platform/concurrency.md -- Swift 6 strict concurrency migration, approachable concurrency (6.2), actor patterns${CLAUDE_PLUGIN_ROOT}/references/apple-platform/build-automation.md -- Auto-deploy on major commits, Xcode Cloud CI scripts, Fastlane lanes, GitHub Actions workflow${CLAUDE_PLUGIN_ROOT}/references/apple-platform/context-management.md -- Context engineering for long iOS dev sessionsValidation and tooling in ${CLAUDE_PLUGIN_ROOT}/scripts/apple-platform/:
xcode-validate.sh -- Project structure validator (directories, plists, entitlements, bundle IDs, anti-patterns)apple-version-check.sh -- Platform version checker (Xcode, Swift, iOS/watchOS/macOS SDK versions, deployment target comparison, feature availability audit)deploy-testflight.sh -- Full deploy pipeline: version bump → xcodegen → build → commit → push → archive → upload to TestFlightWhen the user asks to "deploy", "push to TestFlight", "upload build", or "commit push deploy", run the deploy script:
# From project root (must have project.yml)
${CLAUDE_PLUGIN_ROOT}/scripts/apple-platform/deploy-testflight.sh [patch|minor|major] ["commit message"]
What it does (6 steps):
xcodegen generatePrerequisites: project.yml with CURRENT_PROJECT_VERSION and MARKETING_VERSION, xcodegen installed, ASC API key at ~/.private_keys/AuthKey_NTNAA84KU6.p8.
For build-loop integration: Call this script in Phase 8 (Report) or after Phase 5 (Validate) passes for iOS projects. Detect iOS projects by checking for project.yml with platform: iOS.